feat(custom-widgets): complete JSX v2
This commit is contained in:
263 files changed
+65503
-17816
No files matched your search
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: homarr-custom-widget
|
||||
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, local state, and Mantine JSX.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
version: "2.0.0"
|
||||
---
|
||||
|
||||
# Homarr Custom Widget
|
||||
|
||||
Create one widget at a time. Repeat the workflow when the user asks for several widgets; do not invent packs or shared connections.
|
||||
|
||||
## Connected workflow
|
||||
|
||||
1. Get `homarr-custom-widget-author`, then read `homarr://custom-widgets/schema` and `homarr://custom-widgets/components`.
|
||||
2. Read the API documentation supplied by the user. Ask for missing base URLs and credentials only when needed.
|
||||
3. Build one `homarr-custom-widget-v2` document. Keep credentials outside the document.
|
||||
4. Call `customWidget_validate`, fix every error, and treat advisory Mantine prop warnings as review items.
|
||||
5. Call `customWidget_previewCreate`, then test load/manual queries with `customWidget_previewQuery` and actions with `customWidget_previewAction`.
|
||||
6. If credentials are missing, call `customWidget_secretRequestUser`. Use `customWidget_secretSet` only when the user already supplied a credential and the tool is authorized.
|
||||
7. Inspect the preview URL and `customWidget_previewJournal`. Patch JSX with `customWidget_templatePatch` or update the draft and repeat.
|
||||
8. Call `customWidget_create` only after validation and preview pass. Continue with the next widget independently.
|
||||
|
||||
## Output rules
|
||||
|
||||
- Use inline sources, named requests, definition-owned options, session-local state, and one safe JSX template.
|
||||
- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.
|
||||
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
|
||||
- Use declarative `bind` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
|
||||
- Include responsive loading, empty, error, and success states.
|
||||
- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.
|
||||
|
||||
Read [references/schema.md](references/schema.md), [references/runtime.md](references/runtime.md), and [references/security.md](references/security.md) before offline authoring. When connected, prefer live resources because installed Mantine components can differ by Homarr release.
|
||||
@@ -0,0 +1,21 @@
|
||||
# JSX runtime
|
||||
|
||||
Templates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `state`.
|
||||
|
||||
Use Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized.
|
||||
|
||||
Render icons as `<TablerIcon name="server" size={18} />`. When connected, retrieve `homarr://custom-widgets/components/TablerIcon` for the supported icon names.
|
||||
|
||||
Use `SubFetch` for a manual query, `ActionButton` or `ToggleSwitch` for an action, and `RefreshButton` to refresh data. A query with `trigger: "load"` populates its data automatically and follows the widget refresh interval.
|
||||
|
||||
Bind local controls declaratively:
|
||||
|
||||
```jsx
|
||||
<TextInput bind="search" label="Search" />
|
||||
<Calendar bind="selectedDate" />
|
||||
<Select bind="selectedEntity" data={options.entities} />
|
||||
```
|
||||
|
||||
Local state lasts for the widget instance and browser session. Put durable user configuration in options. Do not author callbacks.
|
||||
|
||||
Always render a useful loading state from `status`, an empty state, a contained error state, and layouts that work at roughly 320, 480, and 720 pixels wide. Use accessible labels and confirmations. Retrieve `homarr://custom-widgets/components/{name}` for exact installed component guidance.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Custom Widget v2 schema
|
||||
|
||||
Every import and export is one object with `$schema: "homarr-custom-widget-v2"`, metadata, `sources`, `requests`, `optionsSchema`, `defaultOptions`, optional state, and `template`. Secrets are accepted separately by create/update/preview operations and never exported.
|
||||
|
||||
```ts
|
||||
interface HomarrCustomWidgetV2 {
|
||||
$schema: "homarr-custom-widget-v2";
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
sources: CustomWidgetSource[];
|
||||
requests: CustomWidgetRequest[];
|
||||
optionsSchema: CustomWidgetOptionsSchema;
|
||||
defaultOptions: Record<string, unknown>;
|
||||
stateSchema?: Record<string, "string" | "number" | "boolean" | "date" | "string[]" | "number[]" | "date[]">;
|
||||
defaultState?: Record<string, unknown>;
|
||||
template: string;
|
||||
}
|
||||
```
|
||||
|
||||
A source has a stable lowercase `id`, display `name`, `baseUrl`, `networkScope` (`public`, `private`, or `loopback`), and one auth mode: `none`, `bearer`, `basic`, `apiKeyHeader`, or `apiKeyQuery`.
|
||||
|
||||
A request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `queryTemplate` and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Bind a declared parameter with `{name}` in a path or `{ "$param": "name" }` in structured query/body data.
|
||||
|
||||
Options use a restricted object JSON Schema. Set `additionalProperties: false`. Presentation metadata lives under `x-homarr`; dynamic selects use `optionsSource.requestId`, optional `itemsPath` for wrapped arrays, `valuePath`, and `labelPath`. Default options must validate against the schema.
|
||||
|
||||
Option and state names start with a letter and use letters, numbers, `-`, or `_`; camelCase is supported. Every JSX `bind` value is a literal name declared by `stateSchema`.
|
||||
|
||||
Do not model credentials as options or static defaults. Credential-like option names and values are rejected; source authentication is configured separately.
|
||||
|
||||
When connected, retrieve `homarr://custom-widgets/schema` for the exact machine-readable schema.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Security boundary
|
||||
|
||||
Homarr executes requests on the server and constrains them to a declared source. Network scopes, DNS checks, redirect validation, SSRF protection, request/response limits, timeouts, rates, permissions, and secret injection are enforced outside JSX.
|
||||
|
||||
Never include secrets in a widget document. Configure them per widget source. Stored plaintext is never returned, duplicated, or exported.
|
||||
|
||||
The runtime blocks imports, hooks, refs, raw callbacks, polymorphic roots, provider replacement, arbitrary portals, browser `fetch`, `XMLHttpRequest`, arbitrary JavaScript, evaluation, npm packages, prototype-pollution keys, unsafe URLs, global selectors, and CSS escaping the widget root.
|
||||
|
||||
Do not work around a blocked capability. Express network behavior as a named request and interaction as a declarative Homarr component. Require explicit confirmation for destructive actions. Never let actions trigger on load.
|
||||
|
||||
Treat request/response journals and diagnostics as sensitive. Homarr redacts credentials, but an agent should also avoid repeating private URLs, request bodies, or personal data unless necessary to fix the widget.
|
||||
+4
-1
@@ -61,9 +61,12 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
# Enable mock integration
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Public Workshop API origin used by Homarr's Workshop browser. The default is https://homarr.dev.
|
||||
# NEXT_PUBLIC_WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
|
||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
||||
# DEMO_MODE=true
|
||||
|
||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
||||
# DEMO_READ_ONLY=false
|
||||
# DEMO_READ_ONLY=false
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/workshop/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/workshop.yml"
|
||||
push:
|
||||
branches: [dev, v2]
|
||||
paths:
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/workshop/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/workshop.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11.11.0
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @homarr/workshop typecheck
|
||||
- run: pnpm --filter @homarr/docs typecheck
|
||||
- run: pnpm test:workshop
|
||||
- run: docker build --target production -f apps/workshop/Dockerfile .
|
||||
+5
-1
@@ -71,7 +71,11 @@ apps/nextjs/public/images/background.png
|
||||
|
||||
# personal agent config
|
||||
.opencode
|
||||
.agents/
|
||||
.agents/*
|
||||
!.agents/skills/
|
||||
.agents/skills/*
|
||||
!.agents/skills/homarr-custom-widget/
|
||||
!.agents/skills/homarr-custom-widget/**
|
||||
skills-lock.json
|
||||
|
||||
# docusaurus
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
# Custom Widgets Architecture Audit and North Star
|
||||
|
||||
> Status: implemented in the current working tree; baseline retained below for traceability
|
||||
> Evidence snapshot: `c5703e7e5` on PR #6304
|
||||
> Scope: built-in custom-widget displays, Custom JSX, network execution, authoring, preview, import/export, AI prompts, and MCP tools
|
||||
|
||||
## Implementation outcome
|
||||
|
||||
The migration described by this audit is implemented. `@homarr/custom-widgets` now owns the domain through explicit `core`, `jsx`, `runtime`, `server`, and `workbench` exports. API, Widgets, and Next.js retain only authorization, persistence, transport, board registration, navigation, and localized adapter concerns.
|
||||
|
||||
Key outcomes:
|
||||
|
||||
- One typed configuration and display-descriptor model drives schemas, defaults, form hydration, serialization, import/export, preview, examples, diagnostics, generated references, and AI prompts.
|
||||
- Display-data extraction moved out of API and Widgets into the domain package; the former API-to-Widgets re-export chain no longer exists.
|
||||
- The interpreter, shared security policy, registry, wrappers, named-request runtime, hardened executor, preview sessions, rate limits, and workbench UI have focused package-owned modules and tests.
|
||||
- Runtime and workbench network behavior is injected through typed ports. The domain package has no dependency on API, Widgets, Next.js routes, notification infrastructure, or modal infrastructure.
|
||||
- Public tRPC and MCP procedures are characterized, v2 and v3 import envelopes remain accepted, display-only v1 remains covered, and MCP offers iterative schema, validation, template read/write/patch, create/import/update, preview query, simulation, and journal tools.
|
||||
- The AI prompt uses a multiline two-block authoring bundle, documents the `SubFetch` render-child contract, and is generated from the canonical registry and tested examples.
|
||||
- Architecture enforcement currently scans 110 production modules for dependency direction, cycles, file budgets, dynamic loading, and untranslated JSX literals. Generated-reference drift and every Mantine export classification are test-enforced.
|
||||
- The latest production build measured the largest Custom JSX route payload at 99,852 gzip bytes across two chunks, below the 768 KiB budget.
|
||||
|
||||
Focused verification is green: 278 package tests, 35 API/MCP tests, 52 Widgets renderer tests, 7 E2E tests, affected typecheck, affected lint, docs build, production Next.js build, architecture checks, generated drift, and the bundle budget. The root `pnpm test` command is presently blocked on this workstation by unrelated Docker-backed integration suites timing out while the shared Docker daemon is under external load; no Custom Widget test failed in those runs.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The feature has a strong security direction and a capable authoring experience, but its implementation has outgrown its current ownership model. PR #6304 changes 56 files with 13,206 additions. The primary feature directories contain 10,437 lines, spread across `@homarr/widgets`, `@homarr/api`, `@homarr/definitions`, `@homarr/validation`, and the Next.js management route.
|
||||
|
||||
The main problem is not the absolute line count. It is that domain policy, transport, persistence, rendering, form state, compatibility, and documentation generation are interleaved across package boundaries. A change to one display type can require synchronized edits in schemas, form defaults, import conversion, preview extraction, runtime rendering, documentation, and AI instructions. Several of those paths use untyped records, duplicated conversion logic, or direct API dependencies.
|
||||
|
||||
The north star is a single `@homarr/custom-widgets` domain package with explicit `core`, `jsx`, `runtime`, `server`, and `workbench` entry points. Existing applications and packages become thin adapters. The extraction must preserve public APIs, import formats, permission behavior, and the current UI while improving internal cohesion.
|
||||
|
||||
This audit does not authorize a big-bang rewrite. The migration should be a sequence of behavior-preserving PRs, beginning with characterization tests and ending with enforced dependency and complexity budgets.
|
||||
|
||||
## Measured baseline
|
||||
|
||||
### Change footprint
|
||||
|
||||
| Measure | Current value |
|
||||
| ------------------------------- | ------------: |
|
||||
| PR files changed | 56 |
|
||||
| PR additions | 13,206 |
|
||||
| PR deletions | 1,385 |
|
||||
| Primary feature-directory lines | 10,437 |
|
||||
|
||||
Generated component-reference JSON accounts for 2,491 added lines and is not treated as source complexity.
|
||||
|
||||
### Largest production files
|
||||
|
||||
| File | Lines | Responsibilities currently combined |
|
||||
| ------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx` | 1,911 | Schema refinement, defaults, server-error mapping, form conversion, submission, preview state, focus management, display picker, connection fields, and every display-specific editor |
|
||||
| `packages/api/src/router/custom-widget/custom-widget-router.ts` | 978 | CRUD, import/export, template patching, MCP descriptions, preview sessions, preview execution, journals, and action simulation/execution |
|
||||
| `packages/widgets/src/custom-api/safe-jsx-interpreter.tsx` | 944 | AST model, parsing, environments, budgets, sanitization, expression evaluation, safe methods, callbacks, and React rendering |
|
||||
| `apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx` | 834 | Preview orchestration, error boundary, tabs, response tree, diagnostics, request journal, sample data, and renderer selection |
|
||||
| `packages/widgets/src/custom-api/jsx-whitelist.ts` | 775 | Mantine imports, wrappers, icon mapping, runtime component map, registry filtering, data sanitization, and safe bindings |
|
||||
| `packages/widgets/src/custom-api/jsx-sub-fetch.tsx` | 599 | Query rendering, render props, path display, action execution, buttons, toggles, refresh, notifications, and confirmations |
|
||||
|
||||
These files exceed the proposed 300-line production-module target. The interpreter is a reasonable candidate for a documented 400-line exception after its independent policies, evaluators, and renderer are extracted.
|
||||
|
||||
### Current ownership
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D["@homarr/definitions<br/>registry and examples"]
|
||||
V["@homarr/validation<br/>schemas and AST diagnostics"]
|
||||
W["@homarr/widgets<br/>renderers and interpreter"]
|
||||
A["@homarr/api<br/>CRUD, permissions, execution and MCP"]
|
||||
N["Next.js route<br/>workbench, preview and prompt"]
|
||||
X["Docs<br/>generated reference and guides"]
|
||||
|
||||
N --> A
|
||||
N --> V
|
||||
N --> D
|
||||
N --> W
|
||||
W --> A
|
||||
W --> D
|
||||
A --> V
|
||||
X --> D
|
||||
```
|
||||
|
||||
The `@homarr/widgets → @homarr/api` edge is particularly costly. Runtime request components call the generated tRPC client directly, so moving the domain into a package that is also consumed by the API would create a dependency cycle unless request execution is inverted behind a typed port.
|
||||
|
||||
## What is working well
|
||||
|
||||
- Custom JSX v2 replaces raw runtime URLs and methods with named, typed requests.
|
||||
- Board permissions, network scope, DNS validation, redirect policy, response limits, caching, concurrency, and action rules are server-enforced.
|
||||
- The safe interpreter no longer treats an upstream JSX parser as a sandbox.
|
||||
- Component metadata drives runtime classification, editor completion, docs generation, and part of the AI prompt.
|
||||
- The workbench supports multiline JSX, diagnostics, real preview sizes, response inspection, request journals, import review, undo/redo, and paste-to-import.
|
||||
- MCP exposes schema, validation, preview, journal, template read/write/patch, import, and update workflows without exposing stored secrets.
|
||||
- Shared examples cover simple metrics, nested queries, charts, actions, and richer interactive layouts.
|
||||
|
||||
The migration must preserve these capabilities. Package extraction is not a reason to weaken the security boundary or remove authoring features.
|
||||
|
||||
## Findings
|
||||
|
||||
### P1 — Domain ownership is fragmented
|
||||
|
||||
Custom-widget behavior is implemented in five packages/applications. None of them is the clear source of truth for the complete lifecycle. `@homarr/definitions` owns component metadata and examples, `@homarr/validation` owns schemas and template diagnostics, `@homarr/widgets` owns runtime evaluation, `@homarr/api` owns network and persistence flows, and Next.js owns authoring transformations.
|
||||
|
||||
Impact:
|
||||
|
||||
- Maintainers must search unrelated packages to understand one behavior.
|
||||
- Changes have a large synchronization surface and can drift silently.
|
||||
- The domain cannot be tested or reused as a coherent unit.
|
||||
- Package names describe infrastructure location rather than feature ownership.
|
||||
|
||||
### P1 — Giant modules mix unrelated reasons to change
|
||||
|
||||
The largest files are not merely long; they combine policy, orchestration, state, and presentation. The form contains all display editors. The router contains every management and preview procedure. The interpreter contains parsing through React emission. The request component file contains both query and mutation interaction models.
|
||||
|
||||
Impact:
|
||||
|
||||
- Reviews are broad and regression-prone.
|
||||
- Unit-test seams are difficult to identify.
|
||||
- Merge conflicts concentrate in a small number of files.
|
||||
- Small feature changes require understanding thousands of lines of adjacent behavior.
|
||||
|
||||
### P1 — Runtime code depends directly on the API client
|
||||
|
||||
`packages/widgets/src/custom-api/component.tsx` and `jsx-sub-fetch.tsx` import `@homarr/api/client`. They also directly invoke notifications and confirmation modals.
|
||||
|
||||
Impact:
|
||||
|
||||
- The renderer cannot be used or tested without application infrastructure.
|
||||
- A future `@homarr/custom-widgets` package would cycle with `@homarr/api` if both retained these imports.
|
||||
- Preview and board runtime cannot share the same renderer through different request implementations without branching inside the components.
|
||||
|
||||
Required direction: a runtime provider must receive typed query, action, confirmation, notification, and invalidation ports. The board and preview layers supply adapters.
|
||||
|
||||
### P1 — Configuration transformations are duplicated
|
||||
|
||||
Create defaults and builders live in the form, edit hydration lives in the edit page, and import hydration lives in `_custom-widget-import-utils.ts`. These paths independently translate between stored display configs and form values. They rely on display-type switches, record casts, and JSON text fields such as the request manifest.
|
||||
|
||||
Impact:
|
||||
|
||||
- Create, edit, paste replacement, import, export, and MCP can disagree.
|
||||
- Adding a field requires updating multiple transforms.
|
||||
- Round trips are difficult to prove lossless.
|
||||
|
||||
Required direction: each display type supplies one typed descriptor containing schema, defaults, `toForm`, `fromForm`, preview projection, and migration behavior.
|
||||
|
||||
### P1 — Type safety degrades at domain boundaries
|
||||
|
||||
`Record<string, unknown>`, `as never`, and `as unknown as` are frequent in form conversion, display extraction, preview state, imports, registry wrappers, and API parsing. Some casts are necessary at the interpreter/React boundary, but most domain-level casts indicate missing discriminated unions or validated adapters.
|
||||
|
||||
Impact:
|
||||
|
||||
- Invalid combinations survive until runtime.
|
||||
- Refactors do not receive complete compiler guidance.
|
||||
- Server and client transformations can diverge while continuing to compile.
|
||||
|
||||
Required direction: parse unknown values once at ingress and use typed domain objects afterward. Unsafe casts must be isolated to named boundary modules and justified by tests.
|
||||
|
||||
### P1 — Security policy is represented more than once
|
||||
|
||||
The template validator and interpreter both parse JSX and maintain concepts such as blocked properties, blocked props, AST depth, and operation limits. Runtime component filtering adds another policy layer.
|
||||
|
||||
The analyzer and interpreter have different jobs, so they should not be collapsed into one evaluator. They must, however, consume one immutable policy definition and shared AST normalization utilities.
|
||||
|
||||
Impact:
|
||||
|
||||
- A property can be rejected by one layer and missed by another.
|
||||
- Security changes require parallel edits and duplicate tests.
|
||||
- Diagnostics can describe behavior that differs from runtime enforcement.
|
||||
|
||||
### P1 — The API router combines transport and application behavior
|
||||
|
||||
The router owns tRPC metadata, Zod inputs, database writes, encryption, import/export, template revisions, preview execution, rate-limit acquisition, journal recording, and MCP documentation.
|
||||
|
||||
Impact:
|
||||
|
||||
- Domain logic is only testable through tRPC context setup.
|
||||
- MCP and browser endpoints can accidentally implement different workflows.
|
||||
- Framework-specific `TRPCError` values leak into lower-level executor and manifest code.
|
||||
|
||||
Required direction: tRPC procedures authorize, load persistence models, call a domain service, and map domain errors. They do not implement request rendering, template patching, preview execution, or import conversion inline.
|
||||
|
||||
### P2 — Metadata generation is only partially centralized
|
||||
|
||||
The component registry and examples are shared, but runtime maps, wrappers, safe bindings, display-type metadata, AI prompt prose, form choices, and some docs remain separately assembled. The AI prompt is built inside a client button component.
|
||||
|
||||
Impact:
|
||||
|
||||
- Agent instructions can drift from validation and runtime behavior.
|
||||
- Prompt generation requires UI and network context.
|
||||
- Display types lack the same single-registry guarantee as JSX components.
|
||||
|
||||
Required direction: prompt generation is a pure function in `core`, using the schema, display descriptors, component registry, and tested fixtures. The UI only fetches context and copies the result.
|
||||
|
||||
### P2 — Compatibility behavior is mixed into primary flows
|
||||
|
||||
V1 detection, migration warnings, legacy network diagnostics, v2 imports, v3 exports, and fallback display parsing are embedded beside current authoring/runtime logic.
|
||||
|
||||
Impact:
|
||||
|
||||
- Happy paths are harder to read.
|
||||
- Legacy behavior can accidentally influence v2 code.
|
||||
- Compatibility retirement has no obvious deletion boundary.
|
||||
|
||||
Required direction: version detection and migrations live under `core/migrations`. Runtime and workbench receive a normalized current model plus explicit review state.
|
||||
|
||||
### P2 — Test topology does not mirror the architecture
|
||||
|
||||
Interpreter coverage is substantial, and request-executor/router tests exist, but tests are colocated under their current infrastructure owners instead of a feature package. A focused renderer run passed 52 tests during review, while the selected API suites failed before collecting tests because the root Vitest client environment rejected server-only environment access.
|
||||
|
||||
Impact:
|
||||
|
||||
- The complete feature cannot be verified with one package command.
|
||||
- Server test failures can be configuration failures rather than behavioral failures.
|
||||
- Form conversion and workbench orchestration have limited direct characterization coverage relative to their size.
|
||||
|
||||
Required direction: the package owns focused test projects or environment-specific configs for core, DOM runtime/workbench, and Node server tests. API adapter tests remain in `@homarr/api`.
|
||||
|
||||
### P2 — A small amount of user-facing copy bypasses translation
|
||||
|
||||
Examples found during the snapshot include the raw-response tooltip and a default empty template message. Dynamic translation keys also require repeated `as never` casts, reducing translation-key safety.
|
||||
|
||||
Impact:
|
||||
|
||||
- Locale completeness gates are unreliable.
|
||||
- Refactoring copy is harder than changing typed translation keys.
|
||||
|
||||
Required direction: all package UI receives a typed message catalog or uses typed scoped translations. No visible fallback literals are allowed in production components.
|
||||
|
||||
## North-star package
|
||||
|
||||
```text
|
||||
packages/custom-widgets/
|
||||
├── src/core/
|
||||
│ ├── config/ # discriminated display configurations and form models
|
||||
│ ├── displays/ # typed display descriptors and built-in display metadata
|
||||
│ ├── import/ # v2/v3 parsing, export, review, and authoring bundles
|
||||
│ ├── migrations/ # v1 detection, normalization, and review requirements
|
||||
│ ├── prompts/ # pure AI prompt generation
|
||||
│ └── examples/ # typed fixtures shared by tests, docs, editor, and prompts
|
||||
├── src/jsx/
|
||||
│ ├── ast/ # parser adapter, AST types, traversal, and normalization
|
||||
│ ├── policy/ # blocked properties, props, URLs, and budgets
|
||||
│ ├── analyzer/ # authoring diagnostics
|
||||
│ ├── interpreter/ # expression and collection evaluation
|
||||
│ ├── registry/ # component descriptors, wrappers, and export classification
|
||||
│ └── bindings/ # branded safe helpers and sanitized data
|
||||
├── src/runtime/
|
||||
│ ├── displays/ # built-in renderers
|
||||
│ ├── jsx/ # Custom JSX renderer and error containment
|
||||
│ ├── requests/ # SubFetch, actions, toggles, refresh, and state handling
|
||||
│ └── provider/ # injected runtime capabilities
|
||||
├── src/server/
|
||||
│ ├── network/ # hardened executor and destination policy
|
||||
│ ├── requests/ # manifest resolution and parameter substitution
|
||||
│ ├── limits/ # rate and concurrency limits
|
||||
│ └── preview/ # sessions, simulation, and journals
|
||||
└── src/workbench/
|
||||
├── form/ # shared state and connection/display sections
|
||||
├── editor/ # CodeMirror and registry-driven diagnostics
|
||||
├── preview/ # widget, response, journal, and diagnostic views
|
||||
└── import/ # clipboard parsing and review UI
|
||||
```
|
||||
|
||||
Public exports are explicit:
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
"./core": "./src/core/index.ts",
|
||||
"./jsx": "./src/jsx/index.ts",
|
||||
"./runtime": "./src/runtime/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./workbench": "./src/workbench/index.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There is no broad root barrel. Internal folders cannot be imported by consumers.
|
||||
|
||||
### Dependency rules
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["custom-widgets/core"]
|
||||
J["custom-widgets/jsx"]
|
||||
R["custom-widgets/runtime"]
|
||||
S["custom-widgets/server"]
|
||||
B["custom-widgets/workbench"]
|
||||
API["@homarr/api adapter"]
|
||||
W["@homarr/widgets adapter"]
|
||||
N["Next.js route adapter"]
|
||||
DOC["Docs generator"]
|
||||
|
||||
J --> C
|
||||
R --> C
|
||||
R --> J
|
||||
S --> C
|
||||
B --> C
|
||||
B --> R
|
||||
API --> C
|
||||
API --> S
|
||||
W --> C
|
||||
W --> R
|
||||
N --> C
|
||||
N --> B
|
||||
DOC --> C
|
||||
DOC --> J
|
||||
```
|
||||
|
||||
Hard rules:
|
||||
|
||||
1. `@homarr/custom-widgets` never imports `@homarr/api`, `@homarr/widgets`, or Next.js route modules.
|
||||
2. `core` has no React, Mantine, tRPC, database, Redis, or browser dependency.
|
||||
3. `jsx` has no API, persistence, Redis, notification, or modal dependency.
|
||||
4. `runtime` performs no direct tRPC calls. It consumes `CustomWidgetRuntimePort`.
|
||||
5. `workbench` performs no direct tRPC calls. It consumes `CustomWidgetWorkbenchPort` callbacks.
|
||||
6. `server` has no tRPC or database-query dependency. API adapters load authorized records and map errors.
|
||||
7. Cross-entry imports follow the arrows above; reverse imports fail CI.
|
||||
|
||||
### Runtime ports
|
||||
|
||||
The exact transport remains owned by adapters. The domain contract should cover behavior rather than tRPC details:
|
||||
|
||||
```ts
|
||||
interface CustomWidgetRuntimePort {
|
||||
getData(input: { itemId: string; signal?: AbortSignal }): Promise<CustomWidgetRequestResult>;
|
||||
query(input: NamedRequestInvocation & { signal?: AbortSignal }): Promise<CustomWidgetRequestResult>;
|
||||
action(input: NamedRequestInvocation & { confirmed?: boolean }): Promise<CustomWidgetRequestResult>;
|
||||
confirm(input: ActionConfirmation): Promise<boolean>;
|
||||
notify(input: RuntimeNotification): void;
|
||||
invalidate(input: RuntimeInvalidation): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Board runtime and preview runtime implement the same port. Preview simulation is an adapter capability, not a conditional tRPC import inside `ActionButton`.
|
||||
|
||||
### Display descriptors
|
||||
|
||||
Every display type is represented once:
|
||||
|
||||
```ts
|
||||
interface CustomWidgetDisplayDescriptor<TConfig, TForm> {
|
||||
type: CustomWidgetDisplayType;
|
||||
configSchema: ZodType<TConfig>;
|
||||
formSchema: ZodType<TForm>;
|
||||
createDefault(): TConfig;
|
||||
toForm(config: TConfig): TForm;
|
||||
fromForm(form: TForm): TConfig;
|
||||
migrate(input: unknown): DisplayMigrationResult<TConfig>;
|
||||
extract(response: unknown, config: TConfig): DisplayModel;
|
||||
capabilities(config: TConfig): CustomWidgetCapability[];
|
||||
}
|
||||
```
|
||||
|
||||
UI fields and renderer components may be registered in runtime/workbench companion maps keyed by the same typed display identifier. Domain descriptors remain serializable and framework-independent.
|
||||
|
||||
## Developer-experience north star
|
||||
|
||||
### Maintainers
|
||||
|
||||
- Start at `packages/custom-widgets` for every feature behavior.
|
||||
- Find one module per concept and one descriptor per display type.
|
||||
- Add a display type without editing central switches in multiple packages.
|
||||
- Run one package command for core, DOM, and server tests.
|
||||
- Receive compile-time failures when a configuration field is not handled by forms, preview, migration, or export.
|
||||
- Review PRs that move one domain boundary at a time.
|
||||
|
||||
### Administrators and template authors
|
||||
|
||||
- Preserve the current continuous form and live workbench.
|
||||
- Keep preview usable before a live response exists through schema-valid defaults and editable sample data.
|
||||
- Keep diagnostics actionable, localized, clickable, and consistent with runtime policy.
|
||||
- Keep imports reviewable and secrets excluded.
|
||||
- Keep query and action capabilities visible without noisy permanent warnings.
|
||||
|
||||
### AI agents
|
||||
|
||||
- Obtain schema, registry, limits, examples, and prompt rules from the same typed sources.
|
||||
- Use readable multiline templates through authoring bundles and line-based template tools.
|
||||
- Validate, preview, inspect journals, patch, and revalidate without rewriting unrelated configuration.
|
||||
- Receive stable machine-actionable issue codes in addition to localized or human-readable messages.
|
||||
- Never need to infer runtime behavior from UI-only prompt strings.
|
||||
|
||||
## Fragmentation rules
|
||||
|
||||
- Production modules target at most 300 lines, excluding generated files, fixtures, and type-only declarations.
|
||||
- Algorithmic modules can reach 400 lines only with a file-level rationale and focused tests.
|
||||
- A module has one primary reason to change; moving lines without separating policy from orchestration does not satisfy the target.
|
||||
- React components do not contain schema definitions or persistence transforms.
|
||||
- Routers do not contain reusable domain algorithms.
|
||||
- Domain functions do not throw framework-specific errors.
|
||||
- Unknown input is validated once. Parsed values are not carried as `Record<string, unknown>` through internal layers.
|
||||
- Compatibility code lives in versioned migration modules and has explicit removal criteria.
|
||||
- Generated artifacts are reproducible and checked for drift, but excluded from line budgets.
|
||||
|
||||
## Migration roadmap
|
||||
|
||||
### PR 1 — Characterize contracts
|
||||
|
||||
- Add round-trip tests for every display descriptor: default → form → config, stored config → form → config, and import → export.
|
||||
- Add fixtures for v1 display-only, v1 network-review-required, v2 import, and v3 export.
|
||||
- Add prompt snapshots sourced from the component registry and examples.
|
||||
- Split Node and DOM Vitest projects so server suites collect and run reliably.
|
||||
- Record bundle and render baselines before moving modules.
|
||||
|
||||
Gate: all existing behavior is represented by tests, with no production moves yet.
|
||||
|
||||
### PR 2 — Establish `core`
|
||||
|
||||
- Create `@homarr/custom-widgets` and explicit exports.
|
||||
- Move custom-widget schemas, typed configs, display descriptors, examples, import/export, migration, capability analysis, and prompt generation into `core`.
|
||||
- Replace form/edit/import conversion duplication with descriptor calls.
|
||||
- Temporarily re-export legacy imports from definitions and validation to keep consumers stable.
|
||||
|
||||
Gate: create, edit, import, export, MCP validation, and AI prompt generation use the same domain contracts.
|
||||
|
||||
### PR 3 — Extract JSX policy and interpreter
|
||||
|
||||
- Move AST types, parser adapter, shared security policy, analyzer, evaluator, budgets, bindings, and registry into `jsx`.
|
||||
- Separate evaluator modules by expression family and React emission.
|
||||
- Generate diagnostics and runtime enforcement from the same policy constants.
|
||||
- Keep the Mantine export-classification CI check.
|
||||
|
||||
Gate: the security suite passes unchanged, analyzer/runtime policy drift tests pass, and no consumer imports JSX internals.
|
||||
|
||||
### PR 4 — Extract runtime
|
||||
|
||||
- Move built-in renderers, Custom JSX display, SubFetch, actions, toggles, refresh, and error containment into `runtime`.
|
||||
- Introduce the runtime port and adapters for board and preview contexts.
|
||||
- Remove direct imports of the API client, modals, and notifications from domain components.
|
||||
- Split request components into query, action, toggle, refresh, feedback, and shared-state modules.
|
||||
|
||||
Gate: board rendering and workbench preview use the same runtime components through different adapters; actions retain confirmation, rollback, invalidation, and permission behavior.
|
||||
|
||||
### PR 5 — Extract server services
|
||||
|
||||
- Move executor, manifests, network policy, limits, caching/single-flight, and preview-session logic into `server`.
|
||||
- Replace `TRPCError` below the adapter with typed domain errors.
|
||||
- Split API procedures into management, templates, preview, and runtime routers that delegate to package services.
|
||||
- Keep authorization, database lookup, encryption, and tRPC/MCP metadata in `@homarr/api`.
|
||||
|
||||
Gate: browser and MCP flows call the same services; request security tests run as Node package tests; public procedure names and inputs remain compatible.
|
||||
|
||||
### PR 6 — Extract and fragment workbench
|
||||
|
||||
- Move reusable editor, preview, response tree, import review, connection section, display picker, and display-specific fields into `workbench`.
|
||||
- Introduce a workbench port for validate, preview, query, simulate action, journal, and save callbacks.
|
||||
- Keep Next.js pages responsible only for routing, loading initial data, and constructing adapters.
|
||||
- Replace the monolithic form with small sections and descriptor-driven display fields while preserving the continuous form, two-pane scrolling, autofocus, and mobile modes.
|
||||
|
||||
Gate: no workbench source file exceeds the agreed budget, and create/edit/paste replacement share one state model.
|
||||
|
||||
### PR 7 — Remove scaffolding and enforce boundaries
|
||||
|
||||
- Remove compatibility re-exports and old implementations after all consumers move.
|
||||
- Add CI checks for forbidden imports, dependency cycles, source-size budgets, untranslated literals, generated-artifact drift, component classification, and custom-JSX chunk size.
|
||||
- Update contributor and architecture documentation with package entry points and extension recipes.
|
||||
|
||||
Gate: old ownership paths contain only thin adapters or are deleted.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
### Core
|
||||
|
||||
- Every display descriptor accepts valid data and rejects mismatched configuration.
|
||||
- All create/edit/import/export transformations are lossless where expected.
|
||||
- V1/v2/v3 compatibility and review states are deterministic.
|
||||
- Prompt output, authoring bundles, fixtures, docs data, and completion data are generated from shared sources.
|
||||
|
||||
### JSX
|
||||
|
||||
- Computed and Unicode-obfuscated reflective access remains blocked.
|
||||
- Analyzer and interpreter share property, prop, URL, and budget policy.
|
||||
- Own-property resolution, branded calls, callback accounting, and node/operation limits remain enforced.
|
||||
- Every upstream Mantine export is classified and every enabled runtime component resolves.
|
||||
|
||||
### Runtime and workbench
|
||||
|
||||
- Query cancellation, stale results, retries, render children, action locks, DELETE confirmation, toggle rollback, and invalidation behave identically in preview and boards.
|
||||
- Error boundaries isolate template and display failures.
|
||||
- Empty, sample, stale, loading, failure, and success states are localized and accessible.
|
||||
- Keyboard, focus, responsive, reduced-motion, and narrow-widget behavior remain covered.
|
||||
|
||||
### Server and adapters
|
||||
|
||||
- Board IDOR, permission levels, anonymous actions, named-request kinds, substitutions, DNS classes, redirects, headers, response limits, caching, and rate limits remain covered.
|
||||
- Server tests run under an explicit Node environment rather than failing during client-environment initialization.
|
||||
- MCP schemas and descriptions remain discoverable, stable, and absent from unsafe runtime procedures.
|
||||
|
||||
### Required commands
|
||||
|
||||
At the end of the migration, the package and repository must pass:
|
||||
|
||||
```sh
|
||||
pnpm --filter @homarr/custom-widgets test
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
pnpm turbo typecheck --affected
|
||||
pnpm turbo lint --affected
|
||||
pnpm turbo build --filter=@homarr/docs
|
||||
pnpm check:custom-widget-bundle # after the Next.js production build
|
||||
```
|
||||
|
||||
CI additionally checks dependency direction, cycles, file budgets, generated references, translations, and client bundle size.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
- `@homarr/custom-widgets` owns every reusable custom-widget domain behavior.
|
||||
- No circular dependencies exist.
|
||||
- The package does not import `@homarr/api`, `@homarr/widgets`, or Next.js route modules.
|
||||
- Runtime and workbench networking uses injected typed ports.
|
||||
- One typed descriptor is the source of truth for each display type.
|
||||
- One shared JSX policy drives authoring diagnostics and runtime enforcement.
|
||||
- Create, edit, import, export, preview, MCP, and board runtime use the same typed transformations.
|
||||
- Public tRPC/MCP names, import formats, stored configuration, and permission behavior remain compatible.
|
||||
- V1 display-only migration and network-review states remain tested.
|
||||
- Production modules satisfy the 300/400-line budgets.
|
||||
- No untranslated user-facing strings remain.
|
||||
- Core, JSX, runtime, server, workbench, integration, E2E, accessibility, docs, and bundle checks pass.
|
||||
- A maintainer can locate a custom-widget behavior from the package structure without searching unrelated packages.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- Replacing Mantine or Homarr's visual language.
|
||||
- Allowing arbitrary cross-origin requests.
|
||||
- Replacing named requests with general JavaScript execution.
|
||||
- Changing stored secrets or exposing them through export or MCP.
|
||||
- Redesigning the current management UX during the package extraction.
|
||||
- Removing compatibility formats before migration and review flows are proven.
|
||||
|
||||
## Decision record
|
||||
|
||||
- The package owns all Custom Widget behavior, not only Custom JSX.
|
||||
- Security and correctness are hard constraints.
|
||||
- Maintainer simplicity is the primary tie-breaker after those constraints.
|
||||
- Current user and agent workflows are preserved during extraction.
|
||||
- Generated artifacts do not count toward source-size budgets.
|
||||
- Migration occurs through reviewable, behavior-preserving PRs rather than a single rewrite.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Direct, capable, and approachable. Copy should be concise and technical when precision matters, while the interface should remain familiar to users who are not developers.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Avoid decorative admin dashboards, wizard flows that fight ordinary scrolling, hidden configuration magic, inconsistent controls, excessive modal usage, and interfaces that expose implementation complexity before the user needs it.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Keep the user in control by making configuration and consequences visible.
|
||||
2. Prefer familiar product patterns and progressive disclosure over custom interaction concepts.
|
||||
3. Make simple tasks short without limiting advanced use cases.
|
||||
4. Isolate failures so one service or widget cannot disrupt the dashboard.
|
||||
5. Use the existing Mantine design language consistently across management and board surfaces.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, and actionable error messages.
|
||||
@@ -108,3 +108,4 @@ The advanced deployments environment variables should only be used if you know w
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | -------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||
| `NEXT_PUBLIC_WORKSHOP_API_URL` | Build-time public origin for a separately hosted Workshop PocketBase service. Defaults to `https://homarr.dev`. |
|
||||
@@ -27,11 +27,11 @@ So make sure to manually whitelist this address.
|
||||
|
||||
### Can I add my own widgets? Do I need programming skills?
|
||||
|
||||
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.
|
||||
Yes. [Custom Widgets](/docs/management/custom-widgets) can read public or private HTTP APIs, render responsive Mantine JSX, expose per-board options, and run permission-checked actions. Custom JSX v2 is the only custom-widget format.
|
||||
|
||||
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 can author the JSON and JSX directly, use the one-click prompt with ChatGPT or another assistant, or let an MCP agent validate and preview a widget before saving it. The workbench parses a pasted AI response, shows request capabilities, and produces redacted diagnostics for another iteration.
|
||||
|
||||
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.
|
||||
The template is declarative: it cannot import packages, execute arbitrary JavaScript, or call browser `fetch`. Network operations are named, same-origin server requests protected by Homarr's board permissions and network policy. You should still review origins, methods, permissions, and actions before installing or publishing a widget.
|
||||
|
||||
### Is it secure to expose Homarr to the web?
|
||||
|
||||
|
||||
@@ -83,3 +83,12 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
- Configure global board defaults such as public home boards and status behavior
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Custom Widget Authoring Resources
|
||||
|
||||
Custom JSX v2 has two stable HTTP discovery endpoints:
|
||||
|
||||
- `GET /api/custom-widgets/authoring-prompt` returns the concise current authoring prompt and live resource identifiers.
|
||||
- `GET /api/custom-widgets/skill` returns the Agent Skill name, version, `SKILL.md`, archive URL, and SHA-256.
|
||||
|
||||
The short-lived credential entry route is generated by `customWidget_secretRequestUser`; do not construct or persist those URLs yourself. Definition CRUD, validation, previews, template patches, and secret writes are exposed through authenticated tRPC and MCP procedures. Exported definitions never include credentials.
|
||||
@@ -1,26 +1,30 @@
|
||||
---
|
||||
sidebar_label: AI and MCP authoring
|
||||
---
|
||||
# Agents and downloadable skill
|
||||
|
||||
# AI and MCP authoring
|
||||
An external agent creates widgets one at a time through Homarr's MCP server. The harness owns any multi-widget task list; Homarr does not create packs or multi-widget drafts.
|
||||
|
||||
Homarr exposes an iterative, validation-first workflow to authenticated administrators. Agents should continue until validation, preview, and diagnostics are clean; they must not invent inline network access.
|
||||
<a className="button button--primary" href="/downloads/homarr-custom-widget-2.0.0.zip" download>
|
||||
Download Custom Widget Skill
|
||||
</a>
|
||||
|
||||
## Recommended loop
|
||||
The archive follows the Agent Skills format and includes `SKILL.md` plus schema, runtime, and security references. Its SHA-256 is `5571b74b2faafe8a9e230231c1915193e889c7afa95acef38a1f70befe14a9e1`.
|
||||
|
||||
1. Read `customWidget_schema` and the generated component/capability reference.
|
||||
2. Read an existing definition with `customWidget_byId`, or create a v3 draft.
|
||||
3. Call `customWidget_validate`; fix every issue using its exact path.
|
||||
4. Call `customWidget_preview` for the base GET and retain the preview session ID.
|
||||
5. Test each named query with `customWidget_previewQuery`.
|
||||
6. Dry-run every action with `customWidget_simulatePreviewAction`.
|
||||
7. Inspect `customWidget_previewJournal` for status, duration, and method coverage.
|
||||
8. Revalidate, then call import/create or a focused update/patch operation.
|
||||
## MCP discovery
|
||||
|
||||
Stored secret values are never readable. Omitting secrets during an update preserves them. MCP actions are simulation-only; enabling live preview actions remains a browser-only administrator decision.
|
||||
- Prompt: `homarr-custom-widget-author`
|
||||
- Resources: `homarr://custom-widgets/schema`, `components`, `components/{name}`, `examples/{name}`, and `skill`
|
||||
- HTTP: `GET /api/custom-widgets/authoring-prompt` and `GET /api/custom-widgets/skill`
|
||||
|
||||
## Template format
|
||||
The authoring-prompt tool description intentionally contains only one sentence. Workflow instructions live in the prompt and link to resources.
|
||||
|
||||
Use multiline JSX when reading or patching templates. Prefer focused template read/write/patch tools over rewriting an escaped one-line JSON string. When a complete portable artifact is needed, use the authoring bundle placeholder plus a separate fenced JSX block.
|
||||
## Agent loop
|
||||
|
||||
The AI prompt is generated from the same registry, examples, request rules, and limits as the validator. Agents should still trust validation output over prompt memory because the installed Homarr version is authoritative.
|
||||
1. Fetch the prompt, live schema, and component catalog.
|
||||
2. Read the external API documentation.
|
||||
3. Build and validate one definition without saving.
|
||||
4. Create a preview session and test its queries/actions.
|
||||
5. Open the returned preview URL with browser tools.
|
||||
6. Set a supplied credential with the dedicated secret-write permission, or ask the user through the ten-minute Homarr credential URL.
|
||||
7. Inspect the redacted journal and patch until valid.
|
||||
8. Create the widget, then repeat for the next requested widget.
|
||||
|
||||
Credential URLs are scoped to one definition/preview, source, and required fields. The agent receives only pending/completed status. Plaintext is encrypted immediately and never returned.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
sidebar_label: Maintainer architecture
|
||||
---
|
||||
|
||||
# Maintainer architecture
|
||||
|
||||
Reusable Custom Widget behavior belongs to `@homarr/custom-widgets`:
|
||||
|
||||
- `core` owns schemas, descriptors, migrations, import/export, examples, diagnostics metadata, and prompt generation.
|
||||
- `jsx` owns AST parsing, shared policy, interpreter, bindings, registry, wrappers, and analysis.
|
||||
- `runtime` owns renderers and named-request controls through injected ports.
|
||||
- `server` owns manifests, network execution, caching, limits, preview sessions, and typed domain errors.
|
||||
- `workbench` owns form contracts, editor analysis, preview UI, response tree, and import review through injected ports.
|
||||
|
||||
The API package retains persistence and board authorization adapters. Widgets retain board registration and runtime transport wiring. Next.js routes retain navigation and shells. The domain package must never import API, widgets, or route modules.
|
||||
|
||||
## Add a display type
|
||||
|
||||
Extend the typed descriptor and discriminated schema, defaults/hydration/serialization, renderer, workbench section, preview, import round-trip fixtures, docs, and translations. Create, edit, import, export, MCP, and preview must all use the same transform.
|
||||
|
||||
## Add a JSX component
|
||||
|
||||
Classify the upstream export, document safety mode and supported props, add a wrapper if behavior needs constraining, and update registry tests. CI fails when a Mantine upgrade introduces an unclassified export.
|
||||
|
||||
## Change networking
|
||||
|
||||
Implement policy below the framework-neutral server boundary and expose infrastructure through a typed port. Add tests for permissions, typed substitution, DNS classes, redirects, headers, timeouts, limits, redaction, cache behavior, and action non-retry semantics before wiring the tRPC adapter.
|
||||
|
||||
Production files target 300 lines. Algorithmic AST/network modules may use a documented 400-line ceiling. Dependency-boundary, cycle, file-size, translation, generated-artifact, component-classification, focused unit, integration, E2E, accessibility, and docs-build gates protect the architecture. After a Next.js production build, `pnpm check:custom-widget-bundle` also enforces a 768 KiB gzip ceiling across the client chunks referenced by Custom JSX.
|
||||
@@ -1,36 +1,34 @@
|
||||
---
|
||||
sidebar_label: Authoring workbench
|
||||
---
|
||||
# Workbench and ChatGPT
|
||||
|
||||
# Authoring workbench
|
||||
The desktop workbench keeps code and preview side by side. Mobile uses Configure and Preview tabs. Save and preview actions stay available while the form scrolls.
|
||||
|
||||
The Custom Widget form is a continuous workflow: choose a display, configure the HTTP connection, configure the display, then test it in the adjacent preview. Each section stays visible so you can revisit earlier choices without losing context.
|
||||
CodeMirror edits requests, options, state, and JSX. The editor supplies installed Mantine components, request/source IDs, option/state names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Diagnostics are copyable and secret-free.
|
||||
|
||||
## Choose a display
|
||||
## Manual workflow
|
||||
|
||||
The display grid is the first product decision. Built-in displays are best for common metrics, tables, statuses, and actions. Choose Custom JSX when the layout needs multiple Mantine components, conditional presentation, charts, dates, or named requests.
|
||||
1. Add the API source and select its network scope and authentication mode.
|
||||
2. Enter credentials. Existing values are shown only as configured; plaintext is never revealed.
|
||||
3. Declare named load/manual queries and actions.
|
||||
4. Define item options and local state.
|
||||
5. Write JSX using `data`, `status`, `options`, and `state`.
|
||||
6. Test requests, preview compact/standard/wide layouts, and inspect runtime and accessibility diagnostics.
|
||||
7. Save, then select the definition and its options in the board widget picker.
|
||||
|
||||
Changing display type preserves shared connection fields and initializes safe display defaults. A Custom JSX template is never blank; new and migrated forms receive the tested starter fixture.
|
||||
## ChatGPT copy/paste
|
||||
|
||||
## Test and preview
|
||||
Enter a short request and optional API documentation URL, then select **Copy AI prompt**. Paste it into ChatGPT or another model. The model must return exactly:
|
||||
|
||||
The preview has four tabs:
|
||||
1. A fenced `json` block with one complete widget and `template: "__HOMARR_TEMPLATE__"`.
|
||||
2. A fenced `jsx` block with the readable template.
|
||||
|
||||
- **Widget** renders at compact, standard, or wide board-like sizes.
|
||||
- **Response** uses a tree to inspect data, copy paths, and insert paths into JSX. The external-response action opens a temporary local blob and never publishes the response.
|
||||
- **Request** shows the redacted base request, named capabilities, status, duration, and preview journal.
|
||||
- **Diagnostics** shows template size, named requests, network scope, and blocking or advisory issues.
|
||||
Use **Paste AI response** to fill the workbench. Enter credentials yourself, test, and preview. If validation or a request fails, select **Copy diagnostics for AI** and paste the correction back into Homarr.
|
||||
|
||||
Only GET base requests run automatically. Preview actions are simulated until an administrator explicitly enables live actions for the five-minute preview session. DELETE still requires confirmation.
|
||||
The prompt is under 8,000 characters. It includes the installed Mantine version and capability rules but not a complete prop list, MCP workflow, server URL, or credentials.
|
||||
|
||||
## Keyboard and clipboard
|
||||
## Options
|
||||
|
||||
The JSX and JSON editors support native Ctrl/Cmd+Z and redo, plus visible toolbar controls. Cursor position, character count, and the 50,000-character limit remain visible.
|
||||
Options use a restricted object JSON Schema. Supported controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, URL, nested groups, conditions, and advanced JSON.
|
||||
|
||||
On the list page, paste a complete v3 export with Ctrl+V or Command+V to import it as new. On an edit page, pasting opens a replacement review. Nothing is applied before the origin, methods, credential inheritance, and permissions are shown.
|
||||
Credentials are not widget options. Credential-like option names and static default values are rejected; configure authentication under the widget's API source so values stay encrypted and out of exports and board configuration.
|
||||
|
||||
For AI output, prefer the authoring bundle format: a JSON block containing `"template": "__HOMARR_TEMPLATE__"`, followed by a JSX block. This keeps multiline JSX readable and avoids JSON escape errors.
|
||||
|
||||
## Save behavior
|
||||
|
||||
Errors block save; warnings do not. Server validation runs again on every create or update. Leaving with dirty changes triggers protection. Secrets omitted during edit remain unchanged, and secrets are never exported.
|
||||
Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath` when the response wraps its result array, then use `valuePath` and `labelPath` relative to each item. These requests run only while configuring the item and use the same protected server executor as widget requests.
|
||||
@@ -1,49 +1,59 @@
|
||||
---
|
||||
sidebar_label: Custom JSX language
|
||||
---
|
||||
# Custom JSX runtime
|
||||
|
||||
# Custom JSX language
|
||||
Templates are declarative JSX interpreted inside a widget-local error boundary. They cannot import modules or run arbitrary JavaScript.
|
||||
|
||||
Custom JSX is a declarative expression language, not browser JavaScript. Homarr parses an explicit AST subset and emits React elements from a classified component registry. Templates cannot access browser globals, native prototypes, constructors, refs, portals, raw event handlers, or direct `fetch`.
|
||||
|
||||
## Data and rendering
|
||||
|
||||
The base GET response is available as `data`. Safe member access, conditionals, arrays, object literals, and interpreter-created collection callbacks can compose displays:
|
||||
## Data roots
|
||||
|
||||
```jsx
|
||||
<Stack gap="xs">
|
||||
{data.services
|
||||
.filter((service) => service.visible)
|
||||
.map((service) => (
|
||||
<Group key={service.id} justify="space-between">
|
||||
<Text>{service.name}</Text>
|
||||
<Badge color={service.online ? "green" : "red"}>{service.status}</Badge>
|
||||
</Group>
|
||||
))}
|
||||
<Stack>
|
||||
<TextInput bind="search" label="Search TV shows" />
|
||||
<SubFetch requestId="search-series" params={{ query: state.search }} />
|
||||
{data["search-series"]?.map((series) => (
|
||||
<Group key={series.tvdbId}>
|
||||
<Text>{series.title}</Text>
|
||||
<ActionButton requestId="monitor-series" params={{ tvdbId: series.tvdbId }}>
|
||||
Monitor
|
||||
</ActionButton>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
```
|
||||
|
||||
Properties are resolved own-only. Reflective names such as `constructor`, `prototype`, `__proto__`, `caller`, `callee`, `arguments`, `bind`, `call`, and `apply` are rejected even when computed or Unicode escaped.
|
||||
- `data.<requestId>` contains sanitized JSON or text results.
|
||||
- `status.<requestId>` contains loading, success, HTTP, and error state.
|
||||
- `options` contains the validated board-item configuration.
|
||||
- `state` contains browser-session local state.
|
||||
|
||||
## Components and props
|
||||
Load-triggered queries populate data automatically and follow the widget refresh interval. `SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` invoke actions.
|
||||
|
||||
Every Mantine export is classified as allowed, wrapped, or denied. The [generated component reference](./#component-reference) is authoritative. Props are allowlisted per component; unsupported props produce warnings and are ignored. Polymorphic roots, unsafe URLs, portals, positioning escapes, refs, and function props are stripped.
|
||||
## Mantine capabilities
|
||||
|
||||
`SubData` is a convenience for simple response paths. Prefer the render-child pattern for rich nested results:
|
||||
Homarr discovers exports from the installed Mantine Core, Dates, and Charts packages and generates prop metadata from their installed TypeScript definitions. Ordinary serializable props pass through. Style props, `style`, and `styles` are sanitized and scoped to the tile. Safe new Mantine exports become available without a hand-maintained allowlist; provider, portal, app-shell, modal, drawer, file, and low-level callback families are classified by the capability denylist.
|
||||
|
||||
Use safe Tabler icons through the resolver, for example `<TablerIcon name="server" size={18} />`. The editor completes installed icon names, and `homarr://custom-widgets/components/TablerIcon` exposes the same names to MCP agents.
|
||||
|
||||
The runtime blocks imports, hooks, refs, callbacks, polymorphic roots, provider replacement, arbitrary portal targets, browser networking, npm packages, evaluation, prototype-pollution keys, unsafe URLs, global selectors, and escaping CSS.
|
||||
|
||||
## Bindings
|
||||
|
||||
Declare state types and defaults, then bind controls:
|
||||
|
||||
```jsx
|
||||
<SubFetch requestId="detail" params={{ id: data.selectedId }}>
|
||||
{(detail, meta) => (
|
||||
<Stack>
|
||||
<Title>{detail.name}</Title>
|
||||
<Text>HTTP {meta.status}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</SubFetch>
|
||||
<TextInput bind="search" />
|
||||
<Calendar bind="selectedDate" />
|
||||
<Switch bind="enabled" />
|
||||
<MultiSelect bind="selectedEntities" data={options.entities} />
|
||||
```
|
||||
|
||||
## Safe helpers and budgets
|
||||
Homarr adapts ordinary `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns plus documented unusual component signatures. State supports primitive values and `string[]`, `number[]`, or `date[]` for multi-value controls. Authored callbacks are not allowed.
|
||||
|
||||
Bindings include restricted conversion, string, array, object, JSON, math, URI, and pure date helpers. Native `Date` objects and native prototypes are not exposed.
|
||||
## Diagnostics
|
||||
|
||||
Templates are limited to 50,000 characters, 10,000 rendered nodes, and explicit AST-depth, operation, collection, and string budgets. Budget errors stop only the Custom JSX renderer and are contained by its error boundary.
|
||||
- `BLOCKED_CAPABILITY`: unsafe behavior; saving fails.
|
||||
- `UNKNOWN_COMPONENT`: the component does not exist; saving fails.
|
||||
- `UNKNOWN_MANTINE_PROP`: advisory; the prop is passed through, not discarded.
|
||||
- `INVALID_PROP_VALUE`: warning or error depending on safety.
|
||||
- `BINDING_UNAVAILABLE`: the component renders, but `bind` is unavailable.
|
||||
- `RUNTIME_RENDER_ERROR`: rendering failed inside this tile only.
|
||||
|
||||
Homarr never silently drops an ordinary prop.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
sidebar_label: Extension recipes
|
||||
---
|
||||
|
||||
# Custom Widget extension recipes
|
||||
|
||||
These recipes keep every authoring and runtime path on the same typed source of truth. Run `pnpm check:custom-widgets` after each structural change and add focused package tests before changing an adapter.
|
||||
|
||||
## Add a built-in display type
|
||||
|
||||
1. Add the discriminated configuration schema in `@homarr/custom-widgets/core`.
|
||||
2. Add one descriptor with its icon, category, localized metadata keys, defaults, hydration, serialization, and validation.
|
||||
3. Add the renderer to the runtime display map. It must consume normalized display data and injected actions only.
|
||||
4. Add a bounded workbench section that edits the descriptor-owned form fields.
|
||||
5. Add one fixture and assert create, edit, v2/v3 import, export, MCP, and preview round trips against it.
|
||||
6. Add translation keys and update the display table in the user guide.
|
||||
|
||||
Do not add route-specific conversion code. `buildDisplayFormValues` and `buildDisplayConfigFromFormValues` are the only form conversion boundary.
|
||||
|
||||
## Add or update a JSX component
|
||||
|
||||
1. Classify the Mantine export as `allowed`, `wrapped`, or `denied` in the component registry. Denied entries require a reason.
|
||||
2. Declare supported props and subcomponents. Function props, refs, portals, polymorphic roots, and unsafe positioning stay denied unless a wrapper removes the unsafe behavior.
|
||||
3. Implement wrappers in the JSX package and register them through the package runtime-component factory.
|
||||
4. Add analyzer and renderer tests for accepted props, stripped props, reflective-property attacks, and the rendered-node budget.
|
||||
5. Regenerate the docs reference and verify the editor completion and AI prompt include the same entry.
|
||||
|
||||
The component-classification test must pass against the locked Mantine version. Never expose an upstream namespace or native prototype as a shortcut.
|
||||
|
||||
## Add a named-request capability
|
||||
|
||||
1. Extend the request schema and manifest renderer in `core` and `server` without accepting a raw runtime URL, method, header, or body.
|
||||
2. Enforce permission, network scope, redirect, DNS, header, body, response, concurrency, and rate policies in the framework-neutral server service.
|
||||
3. Expose infrastructure through a typed server port. Translate domain errors only in the API adapter.
|
||||
4. Add the control to runtime through the injected request port. Queries use query semantics; actions require a gesture and use mutation semantics.
|
||||
5. Add preview simulation and redacted-journal coverage. Live actions remain an explicit administrator-only browser choice.
|
||||
6. Update MCP validation and simulation tools without exposing stored secrets or unrestricted action execution.
|
||||
|
||||
Tests must prove actions are never mounted automatically, polled, retried, cached, or redirected. DELETE must retain full permission and confirmation.
|
||||
|
||||
## Add a workbench feature
|
||||
|
||||
1. Put reusable state, analysis, or presentation in `@homarr/custom-widgets/workbench`.
|
||||
2. Receive localized messages and typed callbacks as props. The package cannot import the tRPC client, Next.js navigation, notifications, or Homarr widget adapters.
|
||||
3. Keep the Next.js component as a transport and page-shell adapter.
|
||||
4. Cover keyboard operation, focus order, reduced motion, 320/768/desktop layouts, dirty-state behavior, and error-boundary recovery.
|
||||
5. Keep each production module below 300 lines and preserve the continuous two-pane form behavior.
|
||||
|
||||
## Change examples or AI authoring
|
||||
|
||||
Examples live as typed shared fixtures. Tests, snippets, documentation data, and the AI prompt consume those fixtures. AI output uses an authoring bundle: a JSON block with `__HOMARR_TEMPLATE__`, followed by a readable multiline JSX block. Do not require agents to escape an entire template into one JSON string.
|
||||
|
||||
After changing a fixture, validate every example, render it, regenerate the component reference, and run the prompt snapshot tests. MCP agents must be able to read a widget, patch template lines with a revision, validate, preview named queries, simulate actions, inspect the journal, and update the definition without receiving secret values.
|
||||
|
||||
## Required checks
|
||||
|
||||
```bash
|
||||
pnpm check:custom-widgets
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
pnpm turbo typecheck --affected
|
||||
pnpm turbo lint --affected
|
||||
pnpm turbo build --filter=@homarr/docs
|
||||
```
|
||||
|
||||
Also run the component-classification, generated-reference drift, bundle-budget, accessibility, and focused package/API suites configured by the repository. A change is incomplete if any create, edit, import, export, MCP, preview, or board-runtime path uses a separate transform.
|
||||
@@ -1,204 +1,49 @@
|
||||
---
|
||||
tags:
|
||||
- Custom Widgets
|
||||
- API
|
||||
- Widgets
|
||||
- JSX
|
||||
tags: [Custom Widgets, API, JSX, MCP]
|
||||
---
|
||||
|
||||
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
|
||||
|
||||
# Custom Widgets
|
||||
|
||||
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 JSX v2 is Homarr's only custom-widget format. A definition owns its metadata, inline API sources, named requests, options, local state, and safe JSX template. Credentials belong to a source but are encrypted separately and never exported.
|
||||
|
||||
## Guides
|
||||
There are no legacy display types, reusable connections, packs, or compatibility modes.
|
||||
|
||||
- [Authoring workbench](./authoring): create, edit, preview, paste, undo, import, and export.
|
||||
- [Custom JSX language](./custom-jsx): supported syntax, components, bindings, diagnostics, and limits.
|
||||
- [Named requests and security](./requests-and-security): permissions, network scopes, request manifests, and server protections.
|
||||
- [AI and MCP authoring](./agent-authoring): an iterative agent workflow that validates and previews before saving.
|
||||
- [Compatibility and migration](./migration): v1/v2 JSX and v2/v3 import behavior.
|
||||
- [Troubleshooting](./troubleshooting): common editor, preview, permission, and networking failures.
|
||||
- [Maintainer architecture](./architecture): package boundaries, extension points, and required tests.
|
||||
- [Extension recipes](./extension-recipes): step-by-step changes for displays, JSX components, requests, and workbench features.
|
||||
## Start here
|
||||
|
||||
:::caution
|
||||
- [Workbench and ChatGPT](./authoring): create widgets manually or use the copy/paste AI loop.
|
||||
- [JSX runtime](./custom-jsx): data roots, Mantine components, bindings, and diagnostics.
|
||||
- [Requests and security](./requests-and-security): sources, actions, permissions, and network protections.
|
||||
- [Agents and downloadable skill](./agent-authoring): MCP prompt/resources/tools and the portable Agent Skill.
|
||||
- [Troubleshooting](./troubleshooting): fix validation, request, configuration, and runtime failures.
|
||||
|
||||
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.
|
||||
## One definition
|
||||
|
||||
:::
|
||||
|
||||
## Create a widget
|
||||
|
||||
Open **Management → Custom Widgets**, select **Add**, and configure:
|
||||
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
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 types
|
||||
|
||||
| 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 |
|
||||
|
||||
## Custom JSX workbench
|
||||
|
||||
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
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
```ts
|
||||
interface HomarrCustomWidgetV2 {
|
||||
$schema: "homarr-custom-widget-v2";
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
sources: CustomWidgetSource[];
|
||||
requests: CustomWidgetRequest[];
|
||||
optionsSchema: CustomWidgetOptionsSchema;
|
||||
defaultOptions: Record<string, unknown>;
|
||||
stateSchema?: Record<string, "string" | "number" | "boolean" | "date" | "string[]" | "number[]" | "date[]">;
|
||||
defaultState?: Record<string, unknown>;
|
||||
template: string;
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
Exports, imports, the UI, MCP, and Workshop all validate this schema. Old files are rejected. If a board references a missing definition, only that tile shows **Custom widget unavailable**; the board and sibling widgets continue rendering.
|
||||
|
||||
`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:
|
||||
## Create and configure
|
||||
|
||||
```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>
|
||||
```
|
||||
Open **Management → Custom Widgets → Add custom widget**. The continuous workbench has sections for general information, API sources and credentials, requests, options, state, JSX, and preview. Use the sticky navigation to jump between sections; there are no artificial “next” steps.
|
||||
|
||||
| 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 |
|
||||
A board item selects a definition from a searchable picker and immediately renders the definition's options. Invalid options block insertion. If an updated definition makes existing configuration invalid, opening the item shows the normal repair form.
|
||||
|
||||
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.
|
||||
## Import, export, and Workshop
|
||||
|
||||
### Network scopes
|
||||
Export downloads one canonical JSON document without credentials. Import validates and shows the origins, auth modes, network scopes, methods, actions, and required board permissions before creating anything.
|
||||
|
||||
| Scope | Allowed destinations |
|
||||
| ---------- | ------------------------------------------------------------ |
|
||||
| `public` | Public IP addresses only |
|
||||
| `private` | Public addresses plus RFC1918 and IPv6 ULA private addresses |
|
||||
| `loopback` | Public, private, and loopback addresses |
|
||||
|
||||
Link-local, cloud-metadata, unspecified, multicast, documentation, carrier-grade NAT, and IPv4-mapped bypass addresses remain blocked in every scope.
|
||||
|
||||
### Example with a named query
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pokemon-detail",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/api/v2/pokemon/{name}",
|
||||
"parameters": { "name": "string" },
|
||||
"auth": "none",
|
||||
"minimumBoardPermission": "view",
|
||||
"cacheTtlSeconds": 300
|
||||
}
|
||||
```
|
||||
|
||||
## Agent authoring through MCP
|
||||
|
||||
Administrators can use Homarr's MCP server to iterate on custom widgets without bypassing validation or network controls. A typical agent workflow is:
|
||||
|
||||
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`.
|
||||
|
||||
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 reference
|
||||
|
||||
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.
|
||||
|
||||
<CustomJsxComponentReference />
|
||||
|
||||
This table and the searchable component list in the editor are generated from Homarr's runtime registry.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## HTTP protections and limits
|
||||
|
||||
All base, preview, query, and action requests use the same server executor:
|
||||
|
||||
- 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.
|
||||
|
||||
## Import and export
|
||||
|
||||
New exports use `"$schema": "homarr-custom-widget-v3"`. Version 2 imports remain accepted. Secrets are never exported and must be entered again after import.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote or report a submission. Workshop administrators may dismiss reports or delete any post; deletion does not remove installed local copies.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
sidebar_label: Compatibility and migration
|
||||
---
|
||||
|
||||
# Compatibility and migration
|
||||
|
||||
Homarr preserves existing display definitions while moving network-enabled Custom JSX to the named-request model.
|
||||
|
||||
## JSX API versions
|
||||
|
||||
Missing `jsxApiVersion` means v1. Display-only v1 templates can be opened and saved as v2 with an empty request manifest. Inline `url`, `method`, `headers`, and body props are inert in v2.
|
||||
|
||||
Static v1 network components can be converted by creating a named request and replacing inline capability props with `requestId` and typed `params`. Dynamic target construction requires an administrator to design an explicit path template and parameter list. Existing non-GET base definitions stop polling and show a network-review state.
|
||||
|
||||
## Import versions
|
||||
|
||||
Exports use `homarr-custom-widget-v3`; v2 imports remain accepted. Import migration normalizes display configuration, preserves v1 semantics, and never imports secret values. The review screen must be accepted before persistence.
|
||||
|
||||
Test migration with the same base response and compare rendered output, query results, action simulation, permissions, and journal entries before enabling the migrated definition on shared boards.
|
||||
@@ -1,32 +1,24 @@
|
||||
---
|
||||
sidebar_label: Requests and security
|
||||
---
|
||||
# Requests and security
|
||||
|
||||
# Named requests and security
|
||||
Each widget owns one or more API sources. Most use a single `default` source. Add another only when one widget genuinely combines APIs, such as a Sonarr/Radarr calendar.
|
||||
|
||||
Custom JSX v2 uses a saved request manifest. Runtime components pass only a `requestId` and declared primitive parameters; they cannot change the URL, method, headers, authentication, or body shape.
|
||||
Sources support no authentication, bearer tokens, basic authentication, API-key headers, and API-key query parameters. Credentials are encrypted per definition, source, and kind. Duplicating or exporting a widget does not copy credentials.
|
||||
|
||||
## Capability kinds
|
||||
## Named requests
|
||||
|
||||
`SubFetch` references a GET `query`. `ActionButton` and `ToggleSwitch` reference a POST, PUT, PATCH, or DELETE `action`. Base data is GET-only. Actions require an explicit gesture and are never polled, cached, retried automatically, or run on mount.
|
||||
Queries and actions support GET, POST, PUT, PATCH, and DELETE. `kind`, not method, controls behavior:
|
||||
|
||||
Default board permissions are view for queries, modify for POST/PUT/PATCH, and full for DELETE. Viewer actions require explicit high-risk approval; anonymous users cannot execute actions. DELETE always requires confirmation.
|
||||
- Queries may load automatically or run through `SubFetch`; they may include a body.
|
||||
- Actions only run after a user gesture; they may require confirmation and invalidate named queries.
|
||||
|
||||
## Parameter substitution
|
||||
Successful actions evict the declared queries from both the browser and server response cache before refetching them.
|
||||
|
||||
Path placeholders use `{name}` and are URL-encoded by the server. Runtime parameters must exactly match all declared names and types. JSON bodies substitute `{ "$param": "name" }` recursively and preserve string, number, or boolean types.
|
||||
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Runtime values can come from validated options, local state, and action input.
|
||||
|
||||
## Network boundary
|
||||
## Protections
|
||||
|
||||
Named paths stay on the base URL origin. HTTP and HTTPS are accepted; URL credentials and fragments are rejected. DNS answers are classified and pinned. Public, private, and loopback scopes progressively allow destinations, but link-local, metadata, unspecified, multicast, documentation, carrier-grade NAT, and IPv4-mapped addresses are always blocked.
|
||||
All requests use Homarr's server executor. It enforces source origin, network scope, DNS and redirect validation, SSRF blocking, permissions, rate limits, request/parameter/body limits, timeouts, response-size limits, and secret injection. Static headers cannot override auth, routing, proxy, cookie, or hop-by-hop headers. Credential-like static values are rejected.
|
||||
|
||||
Queries may follow three revalidated same-origin redirects. Actions do not follow redirects. Credentials never cross origins. Reserved authentication, cookie, host, forwarding, proxy, content-length, security, and hop-by-hop headers cannot be declared.
|
||||
`public` allows public destinations, `private` additionally allows private LAN destinations, and `loopback` additionally allows loopback. Sensitive infrastructure ranges remain blocked according to the executor policy.
|
||||
|
||||
## Limits
|
||||
|
||||
- Request body: 10 KiB UTF-8.
|
||||
- Decompressed response: 1 MiB, 32 JSON levels, 50,000 JSON nodes.
|
||||
- Per user/item: 60 queries, 10 actions, and 3 deletes per minute.
|
||||
- Concurrency: 4 per user/item and 8 per definition.
|
||||
|
||||
Successful GETs can use manifest cache TTLs and single-flight deduplication. Logs contain redacted origin/method/error metadata, never secrets, request bodies, or query values.
|
||||
Every request declares view, modify, or full board access and the server enforces it before contacting the source. DELETE always needs full access and confirmation. Actions are never cached, retried, or triggered on load.
|
||||
@@ -1,33 +1,21 @@
|
||||
---
|
||||
sidebar_label: Troubleshooting
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## The preview says the template is required
|
||||
## Widget unavailable
|
||||
|
||||
New Custom JSX forms always include the starter fixture. If an imported definition has an empty template, reopen its import review; malformed imports must be corrected rather than previewed as JSX.
|
||||
The definition was deleted or reset. Remove the broken tile from the board and install or create a current Custom JSX v2 definition. There is no legacy recovery or migration wizard.
|
||||
|
||||
## A prop is ignored
|
||||
## Validation fails
|
||||
|
||||
The diagnostics name the component and prop. Check the generated component reference. Unsupported props are intentionally removed; use a supported layout primitive or wrapper prop instead of polymorphic, portal, ref, positioning, or raw handler props.
|
||||
Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match options/state schemas, and network components reference the correct request kind.
|
||||
|
||||
## A request does not run
|
||||
## Request fails
|
||||
|
||||
Confirm that the component kind matches the manifest: `SubFetch` needs a query; action controls need an action. Parameter names and primitive types must match exactly. Base requests and queries require GET. Actions only run from a gesture, and edit mode disables controls.
|
||||
Check the source network scope, base URL, credentials, request method/path/body, runtime parameters, and board permission. The preview journal shows redacted method, request ID, path template, status, and duration without credentials.
|
||||
|
||||
## Network scope errors
|
||||
## Mantine prop warning
|
||||
|
||||
Resolve the hostname from the Homarr server environment. Select private or loopback scope only when required and understood. Link-local and metadata destinations cannot be enabled by any scope. Redirects must remain on the base origin.
|
||||
`UNKNOWN_MANTINE_PROP` is advisory and the prop is passed through. Confirm the prop against the installed Mantine version. `BLOCKED_CAPABILITY` is deliberate and must be expressed through safe options, state, bindings, or named requests instead.
|
||||
|
||||
## Permission errors
|
||||
## Existing item needs repair
|
||||
|
||||
The placed board item is the authorization anchor. A definition ID alone cannot authorize credential use. Verify board view/modify/full access, explicit viewer-action approval, and that the caller is not anonymous.
|
||||
|
||||
## Stale or failed preview data
|
||||
|
||||
Connection, authentication, body, method, secret, or manifest changes mark samples stale. Run the GET test again. Background query failures keep prior content visible and offer retry; actions roll toggles back and show error feedback.
|
||||
|
||||
## Import paste is ignored
|
||||
|
||||
Paste a complete v2/v3 JSON export or authoring bundle while the list or edit page is focused. Ordinary text and partial JSON are ignored so normal typing is not intercepted.
|
||||
The definition's options schema changed incompatibly. Open the item settings and repair the highlighted configuration. Homarr does not guess option migrations.
|
||||
@@ -166,54 +166,25 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
||||
|
||||
## Custom Widget Workflow for AI Agents
|
||||
|
||||
AI agents can create and iterate on custom widgets using these tools in sequence.
|
||||
Agents create one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`, then read the live schema and component resources rather than guessing the installed Mantine surface.
|
||||
|
||||
:::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. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
|
||||
2. Build one widget and call `customWidget_validate` without saving.
|
||||
3. Call `customWidget_previewCreate`, then test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
|
||||
4. Open the returned preview URL, inspect `customWidget_previewJournal`, and iterate with `customWidget_templatePatch` or `customWidget_update`.
|
||||
5. Set a supplied credential with `customWidget_secretSet`, or use `customWidget_secretRequestUser` to issue a ten-minute Homarr entry URL. The agent receives completion status, never the plaintext.
|
||||
6. Call `customWidget_create`, then repeat for the next widget.
|
||||
|
||||
### 1. Get the schema
|
||||
Discovery resources:
|
||||
|
||||
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.
|
||||
- `homarr://custom-widgets/schema`
|
||||
- `homarr://custom-widgets/components`
|
||||
- `homarr://custom-widgets/components/{name}`
|
||||
- `homarr://custom-widgets/examples/{name}`
|
||||
- `homarr://custom-widgets/skill`
|
||||
|
||||
### 2. Draft and validate
|
||||
The complete tool surface is `customWidget_schema`, `customWidget_validate`, `customWidget_list`, `customWidget_get`, `customWidget_create`, `customWidget_update`, `customWidget_delete`, `customWidget_templatePatch`, `customWidget_previewCreate`, `customWidget_previewQuery`, `customWidget_previewAction`, `customWidget_previewJournal`, `customWidget_secretSet`, `customWidget_secretRequestUser`, `customWidget_getAuthoringPrompt`, and `customWidget_getSkill`.
|
||||
|
||||
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.
|
||||
`custom-widget-manage` permits authoring. Supplying a secret directly requires the separate `custom-widget-secret-write` permission. Request execution retains Homarr's SSRF, DNS, redirect, response-size, timeout, rate, network-scope, and board-permission protections.
|
||||
|
||||
### 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).
|
||||
:::
|
||||
See [Agents and downloadable skill](./custom-widgets/agent-authoring) for the full loop and portable Agent Skill.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Workshop
|
||||
|
||||
[Open Workshop](/workshop) to discover, inspect, vote on, report, download, and publish Custom JSX v2 widgets.
|
||||
|
||||
Valid submissions publish immediately. Authors can edit or delete their posts, and content changes increment the revision. Widget credentials are rejected and never belong in a submission.
|
||||
|
||||
Votes are unique per user and submission. Public listings show upvotes, downvotes, and score without exposing voter identities. Reports are also unique per reporter and submission and contain a category plus explanation.
|
||||
|
||||
Workshop administrators use a protected administration page to review reports grouped by submission, see reporter identities and explanations, inspect vote totals, dismiss reports, delete any submission, browse unreported posts, and view the audit trail. Workshop has no banning, account-state, moderator, approval, quarantine, badge, or remote-deletion system.
|
||||
|
||||
Deleting a Workshop post never deletes widgets already installed in a self-hosted Homarr instance.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Custom API"
|
||||
description: "Displays data from an administrator-defined HTTP API using built-in renderers or safe Custom JSX."
|
||||
description: "Renders an administrator-defined Custom JSX v2 widget with server-side API requests, options, and actions."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -10,13 +10,13 @@ import { customApiWidget } from ".";
|
||||
|
||||
<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.
|
||||
The Custom API widget displays a Custom JSX v2 definition created in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns one or more inline API sources, named requests, an options schema, local state, and one safe JSX template. Source credentials are encrypted separately and never reach the browser or an export.
|
||||
|
||||
### 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.
|
||||
Choose a definition from the searchable picker, configure its generated options, and select a refresh interval. The board item stores only the selected definition ID and validated option values; credentials remain server-side.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -26,14 +26,12 @@ A definition must be enabled and available to the board before it appears in the
|
||||
|
||||
### 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.
|
||||
Each request declares its minimum board permission independently of its HTTP method. Queries and actions may use GET, POST, PUT, PATCH, or DELETE; `kind` controls runtime behavior. DELETE always requires full board permission and 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.
|
||||
The refresh interval reruns load-triggered queries. Manual queries run through `SubFetch`, and each query may declare its own cache duration. Successful actions can invalidate only their declared load or manual queries.
|
||||
|
||||
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.
|
||||
See [Custom Widgets management](/docs/management/custom-widgets) for the workbench, options, named-request manifest, Mantine JSX surface, network scopes, and security limits.
|
||||
@@ -4,6 +4,7 @@ import { IconApi } from "@tabler/icons-react";
|
||||
export const customApiWidget: WidgetDefinition = {
|
||||
icon: IconApi,
|
||||
name: "Custom API",
|
||||
description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates.",
|
||||
description:
|
||||
"Renders an administrator-defined Custom JSX v2 widget with server-side API requests, options, and actions.",
|
||||
path: "../../widgets/custom-api",
|
||||
};
|
||||
@@ -124,6 +124,11 @@ const config: Config = {
|
||||
position: "left",
|
||||
to: "/blog",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
position: "left",
|
||||
to: "/workshop",
|
||||
},
|
||||
{
|
||||
label: "About us",
|
||||
position: "left",
|
||||
@@ -277,8 +282,19 @@ const config: Config = {
|
||||
function homarrPackagesPlugin() {
|
||||
return {
|
||||
name: "resolve-homarr-packages",
|
||||
configureWebpack() {
|
||||
return { resolve: { symlinks: false } };
|
||||
configureWebpack(_config, isServer, { getJSLoader }) {
|
||||
return {
|
||||
resolve: { symlinks: false },
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.[jt]sx?$/iu,
|
||||
include: /node_modules[\\/]@homarr/u,
|
||||
use: [getJSLoader({ isServer })],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -309,7 +325,6 @@ const config: Config = {
|
||||
},
|
||||
};
|
||||
},
|
||||
"@signalwire/docusaurus-plugin-llms-txt",
|
||||
async function tailwindCssPlugin() {
|
||||
return {
|
||||
name: "docusaurus-tailwindcss",
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
"@docusaurus/plugin-sitemap": "^3.10.1",
|
||||
"@docusaurus/preset-classic": "^3.10.1",
|
||||
"@homarr/definitions": "workspace:^",
|
||||
"@homarr/workshop": "workspace:^",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"clsx": "catalog:",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkshopAdminAction, WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
|
||||
import { WorkshopClient } from "@homarr/workshop";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
export function WorkshopAdmin() {
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient(typeof window === "undefined" ? undefined : window.location.origin),
|
||||
[],
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [actions, setActions] = useState<WorkshopAdminAction[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [nextReports, nextSubmissions, nextActions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listAll({ sort: "newest" }),
|
||||
client.listAdminActions(),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setSubmissions(nextSubmissions);
|
||||
setActions(nextActions);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then((next) => {
|
||||
setUser(next);
|
||||
if (next?.isAdmin) void load();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [client, load]);
|
||||
|
||||
const grouped = submissions.map((submission) => ({
|
||||
submission,
|
||||
reports: reports.filter((report) => report.submission === submission.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className={styles.shell}>
|
||||
<section className={styles.hero}>
|
||||
<h1>Workshop administration</h1>
|
||||
<p>
|
||||
Review reports, see each reporter and vote totals, dismiss reports, or delete any submission. This page does
|
||||
not manage user accounts.
|
||||
</p>
|
||||
{!user && (
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() =>
|
||||
void client.signInWithGitHub().then((next) => {
|
||||
setUser(next);
|
||||
if (next?.isAdmin) void load();
|
||||
})
|
||||
}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
{user && !user.isAdmin && <div className={styles.error}>This Workshop account is not an administrator.</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{user?.isAdmin && (
|
||||
<div className={styles.stack} style={{ marginTop: "2rem" }}>
|
||||
<h2>Submissions and open reports</h2>
|
||||
{grouped.map(({ submission, reports: submissionReports }) => (
|
||||
<section key={submission.id} className={styles.adminGroup}>
|
||||
<div className={styles.meta}>
|
||||
<strong>{submission.title}</strong>
|
||||
<span>
|
||||
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score} ·{" "}
|
||||
{submissionReports.length} reports
|
||||
</span>
|
||||
</div>
|
||||
<p>{submission.description}</p>
|
||||
{submissionReports.map((report) => (
|
||||
<div key={report.id} className={styles.report}>
|
||||
<strong>{report.category}</strong> by {report.reporterName}
|
||||
<p>{report.explanation}</p>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why is this report being dismissed?")?.trim();
|
||||
if (reason) void client.dismissReport(report.id, reason).then(load);
|
||||
}}
|
||||
>
|
||||
Dismiss report
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.actions}>
|
||||
<a className={styles.secondary} href={`/workshop?id=${submission.id}`}>
|
||||
Inspect
|
||||
</a>
|
||||
<button
|
||||
className={styles.danger}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${submission.title}? Installed local copies will remain.`))
|
||||
void client.delete(submission.id, "Deleted after administrator review").then(load);
|
||||
}}
|
||||
>
|
||||
Delete submission
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<h2>Administrator audit trail</h2>
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className={`${styles.adminGroup} ${styles.audit}`}>
|
||||
{new Date(action.created).toLocaleString()} · {action.actorName} · {action.action} · submission{" "}
|
||||
{action.submissionId || "—"} · report {action.reportId || "—"}
|
||||
<br />
|
||||
{action.reason}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
WorkshopReport,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionInput,
|
||||
WorkshopSubmissionSummary,
|
||||
WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
import { validateWorkshopWidget, WorkshopClient } from "@homarr/workshop";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const emptyDraft: WorkshopSubmissionInput = {
|
||||
title: "",
|
||||
description: "",
|
||||
content: "",
|
||||
changelog: "Initial publication",
|
||||
};
|
||||
|
||||
export function WorkshopPage() {
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient(typeof window === "undefined" ? undefined : window.location.origin),
|
||||
[],
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [draft, setDraft] = useState<WorkshopSubmissionInput>(emptyDraft);
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
|
||||
const [reportText, setReportText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await client.list({ search, sort, perPage: 60 })).items);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
|
||||
}
|
||||
}, [client, search, sort]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setSelected(await client.get(id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load submission");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
const requestedSubmission = new URLSearchParams(window.location.search).get("id");
|
||||
if (requestedSubmission) void openDetail(requestedSubmission);
|
||||
return unsubscribe;
|
||||
}, [client, openDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void load(), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (selected && selected.author === user?.id) await client.update(selected.id, draft, screenshots);
|
||||
else await client.create(draft, screenshots);
|
||||
setEditorOpen(false);
|
||||
setDraft(emptyDraft);
|
||||
setScreenshots([]);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to publish");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const edit = () => {
|
||||
if (!selected) return;
|
||||
setDraft({
|
||||
title: selected.title,
|
||||
description: selected.description,
|
||||
content: selected.content,
|
||||
changelog: selected.changelog,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.shell}>
|
||||
<section className={styles.hero}>
|
||||
<h1>Workshop</h1>
|
||||
<p>
|
||||
Discover and publish Custom JSX v2 widgets. Every submission is public immediately; the community can vote and
|
||||
report unsafe content.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
{user ? (
|
||||
<>
|
||||
<span>
|
||||
Signed in as <strong>{user.displayName}</strong>
|
||||
</span>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
onClick={() => {
|
||||
client.signOut();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setDraft(emptyDraft);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Publish widget
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() =>
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: Error) => setError(cause.message))
|
||||
}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className={styles.notice}>
|
||||
Inspect API origins, methods, permissions, and JSX before importing. Workshop never stores widget credentials.
|
||||
</div>
|
||||
<div className={styles.toolbar}>
|
||||
<input
|
||||
className={styles.input}
|
||||
aria-label="Search Workshop"
|
||||
placeholder="Search widgets"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
/>
|
||||
<select
|
||||
className={styles.select}
|
||||
style={{ width: 180 }}
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.currentTarget.value as "top" | "newest")}
|
||||
>
|
||||
<option value="top">Top rated</option>
|
||||
<option value="newest">Newest</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<article key={item.id} className={styles.card}>
|
||||
{item.screenshots[0] ? (
|
||||
<img
|
||||
className={styles.previewImage}
|
||||
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.preview}>JSX</div>
|
||||
)}
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.meta}>
|
||||
<span>Revision {item.revision}</span>
|
||||
<span>
|
||||
▲ {item.upvotes} · ▼ {item.downvotes}
|
||||
</span>
|
||||
</div>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.description || "No description"}</p>
|
||||
<small>by {item.authorName}</small>
|
||||
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
|
||||
Inspect widget
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => event.target === event.currentTarget && setSelected(null)}
|
||||
>
|
||||
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label={selected.title}>
|
||||
<h2>{selected.title}</h2>
|
||||
<p>{selected.description}</p>
|
||||
<div className={styles.meta}>
|
||||
<span>
|
||||
by {selected.authorName} · revision {selected.revision}
|
||||
</span>
|
||||
<span>SHA-256 {selected.contentHash.slice(0, 12)}…</span>
|
||||
</div>
|
||||
{selected.screenshots.length > 0 && (
|
||||
<div className={styles.screenshots}>
|
||||
{selected.screenshots.map((file, index) => (
|
||||
<img
|
||||
key={file}
|
||||
src={client.fileUrl(selected.id, file, "960x640")}
|
||||
alt={`${selected.title} screenshot ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<WidgetCapabilitySummary content={selected.content} />
|
||||
<pre className={styles.code}>{selected.content}</pre>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
disabled={!user}
|
||||
onClick={() =>
|
||||
void client
|
||||
.vote(selected.id, 1)
|
||||
.then(() => openDetail(selected.id))
|
||||
.then(load)
|
||||
}
|
||||
>
|
||||
▲ {selected.upvotes}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
disabled={!user}
|
||||
onClick={() =>
|
||||
void client
|
||||
.vote(selected.id, -1)
|
||||
.then(() => openDetail(selected.id))
|
||||
.then(load)
|
||||
}
|
||||
>
|
||||
▼ {selected.downvotes}
|
||||
</button>
|
||||
<button className={styles.danger} disabled={!user} onClick={() => setReportOpen(true)}>
|
||||
Report
|
||||
</button>
|
||||
{selected.author === user?.id && (
|
||||
<>
|
||||
<button className={styles.secondary} onClick={edit}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className={styles.danger}
|
||||
onClick={() =>
|
||||
void client.delete(selected.id).then(() => {
|
||||
setSelected(null);
|
||||
return load();
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(new Blob([selected.content], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${selected.title}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setSelected(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editorOpen && (
|
||||
<div className={styles.overlay}>
|
||||
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label="Publish widget">
|
||||
<h2>{selected?.author === user?.id ? "Update submission" : "Publish widget"}</h2>
|
||||
<label className={styles.field}>
|
||||
Title
|
||||
<input
|
||||
className={styles.input}
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Description
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft({ ...draft, description: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Canonical widget.json
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
style={{ minHeight: 260 }}
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Changelog
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={draft.changelog}
|
||||
onChange={(event) => setDraft({ ...draft, changelog: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Screenshots
|
||||
<input
|
||||
className={styles.input}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setScreenshots(Array.from(event.currentTarget.files ?? []))}
|
||||
/>
|
||||
<span className={styles.hint}>Up to five images, 5 MiB each.</span>
|
||||
</label>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.button} disabled={busy || !user} onClick={() => void save()}>
|
||||
{busy ? "Publishing…" : "Publish immediately"}
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setEditorOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportOpen && selected && (
|
||||
<div className={styles.overlay}>
|
||||
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label="Report submission">
|
||||
<h2>Report {selected.title}</h2>
|
||||
<label className={styles.field}>
|
||||
Category
|
||||
<select
|
||||
className={styles.select}
|
||||
value={reportCategory}
|
||||
onChange={(event) => setReportCategory(event.currentTarget.value as WorkshopReport["category"])}
|
||||
>
|
||||
{["malicious", "spam", "copyright", "inappropriate", "other"].map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Explanation
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={reportText}
|
||||
onChange={(event) => setReportText(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.danger}
|
||||
disabled={reportText.trim().length < 3}
|
||||
onClick={() =>
|
||||
void client.report(selected.id, reportCategory, reportText).then(() => {
|
||||
setReportOpen(false);
|
||||
setReportText("");
|
||||
})
|
||||
}
|
||||
>
|
||||
Send report
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setReportOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetCapabilitySummary({ content }: { content: string }) {
|
||||
const result = validateWorkshopWidget(content);
|
||||
if (!result.success) return <div className={styles.error}>{result.error}</div>;
|
||||
return (
|
||||
<div className={styles.capabilities}>
|
||||
<section>
|
||||
<strong>API sources</strong>
|
||||
{result.data.sources.map((source) => (
|
||||
<p key={source.id}>
|
||||
<code>{new URL(source.baseUrl).origin}</code> · {source.networkScope} · {source.auth.type}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<section>
|
||||
<strong>Requests and permissions</strong>
|
||||
{result.data.requests.length === 0 ? (
|
||||
<p>No network requests</p>
|
||||
) : (
|
||||
result.data.requests.map((request) => (
|
||||
<p key={request.id}>
|
||||
<code>{request.id}</code> · {request.kind} · {request.method} · {request.minimumBoardPermission}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
.shell {
|
||||
width: min(1180px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0 5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: clamp(1.5rem, 4vw, 3rem);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 12%, transparent), transparent 60%);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.25rem, 6vw, 4.5rem);
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.toolbar,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar {
|
||||
margin: 2rem 0 1.25rem;
|
||||
}
|
||||
.toolbar input {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.button,
|
||||
.secondary,
|
||||
.danger {
|
||||
border: 0;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.7rem 1rem;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button {
|
||||
color: white;
|
||||
background: var(--ifm-color-primary);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
}
|
||||
.danger {
|
||||
color: white;
|
||||
background: var(--ifm-color-danger-dark);
|
||||
}
|
||||
.button:disabled,
|
||||
.secondary:disabled,
|
||||
.danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-color);
|
||||
font: inherit;
|
||||
}
|
||||
.textarea {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hint {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 1rem;
|
||||
background: var(--ifm-card-background-color);
|
||||
}
|
||||
.cardBody {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.cardBody h2,
|
||||
.cardBody p {
|
||||
margin: 0;
|
||||
}
|
||||
.cardBody p {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 160px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
font: 800 1.5rem var(--ifm-font-family-monospace);
|
||||
}
|
||||
.previewImage {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(0 0 0 / 0.62);
|
||||
}
|
||||
.dialog {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: min(820px, 100%);
|
||||
max-height: 90dvh;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
background: var(--ifm-background-color);
|
||||
color: inherit;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
.dialog h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.code {
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
white-space: pre;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.notice,
|
||||
.error,
|
||||
.success {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.notice {
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 20%, transparent);
|
||||
}
|
||||
.error {
|
||||
background: color-mix(in srgb, var(--ifm-color-danger) 20%, transparent);
|
||||
}
|
||||
.success {
|
||||
background: color-mix(in srgb, var(--ifm-color-success) 20%, transparent);
|
||||
}
|
||||
.screenshots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.screenshots img {
|
||||
width: 100%;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.capabilities {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.capabilities section {
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.capabilities p {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.adminGroup {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
.report {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--ifm-color-warning);
|
||||
}
|
||||
.audit {
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shell {
|
||||
width: min(100% - 1rem, 1180px);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.dialog {
|
||||
padding: 1rem;
|
||||
}
|
||||
.actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
|
||||
|
||||
export default function WorkshopAdminRoute() {
|
||||
return (
|
||||
<Layout title="Workshop administration" noFooter>
|
||||
<WorkshopAdmin />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopPage } from "@site/src/components/workshop/WorkshopPage";
|
||||
|
||||
export default function WorkshopRoute() {
|
||||
return (
|
||||
<Layout title="Workshop" description="Discover and publish Homarr Custom JSX widgets">
|
||||
<WorkshopPage />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -57,6 +57,7 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/websocket": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@homarr/workshop": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "catalog:",
|
||||
"@mantine/core": "catalog:",
|
||||
"@mantine/dropzone": "catalog:",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { CustomWidgetSecretEntry } from "./secret-entry";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Configure custom widget credential",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function CustomWidgetSecretPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
return <CustomWidgetSecretEntry token={token} />;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Card, Center, PasswordInput, Stack, Text, TextInput, ThemeIcon, Title } from "@mantine/core";
|
||||
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface RequestDetails {
|
||||
widgetName: string;
|
||||
sourceName: string;
|
||||
kinds: Array<"apiKey" | "username" | "password">;
|
||||
expiresAt: number;
|
||||
status: "pending" | "completed";
|
||||
}
|
||||
|
||||
export function CustomWidgetSecretEntry({ token }: { token: string }) {
|
||||
const t = useScopedI18n("customWidget.secretEntry");
|
||||
const [details, setDetails] = useState<RequestDetails | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetch(`/api/custom-widgets/secret-request/${encodeURIComponent(token)}`)
|
||||
.then(async (response) => {
|
||||
const body = (await response.json()) as RequestDetails | { error: string };
|
||||
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
|
||||
setDetails(body);
|
||||
})
|
||||
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [t, token]);
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/custom-widgets/secret-request/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ secrets: values }),
|
||||
});
|
||||
const body = (await response.json()) as { status?: string; error?: string };
|
||||
if (!response.ok) throw new Error(body.error ?? t("saveError"));
|
||||
setDetails((current) => (current ? { ...current, status: "completed" } : current));
|
||||
setValues({});
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("saveError"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center mih="100dvh" p="md">
|
||||
<Card withBorder shadow="md" radius="lg" p="xl" w="100%" maw={480}>
|
||||
<Stack gap="lg">
|
||||
<ThemeIcon size={48} radius="xl" variant="light">
|
||||
<IconLock size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Title order={1} size="h2">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text c="dimmed">{t("description")}</Text>
|
||||
</Stack>
|
||||
{loading && <Text c="dimmed">{t("loading")}</Text>}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
{details?.status === "completed" && (
|
||||
<Alert color="green" icon={<IconCheck size={18} />}>
|
||||
{t("saved")}
|
||||
</Alert>
|
||||
)}
|
||||
{details?.status === "pending" && (
|
||||
<Stack gap="md">
|
||||
<Card withBorder bg="var(--mantine-color-default-hover)">
|
||||
<Text fw={600}>{details.widgetName}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("source", { name: details.sourceName })}
|
||||
</Text>
|
||||
</Card>
|
||||
{details.kinds.map((kind) => {
|
||||
const Input = kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Input
|
||||
key={kind}
|
||||
label={t(`field.${kind}`)}
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={values[kind] ?? ""}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
loading={saving}
|
||||
disabled={details.kinds.some((kind) => !values[kind])}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("expires", { time: new Date(details.expiresAt).toLocaleTimeString() })}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
{t("link")}
|
||||
</Anchor>{" "}
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import type { CompletionSource } from "@codemirror/autocomplete";
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
@@ -11,12 +11,12 @@ import { linter } from "@codemirror/lint";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react";
|
||||
import { enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
|
||||
import { ComponentReference } from "@homarr/custom-widgets/workbench";
|
||||
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
|
||||
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./_custom-widget-code-language";
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
|
||||
@@ -28,19 +28,6 @@ const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => mo
|
||||
),
|
||||
});
|
||||
|
||||
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}`,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
interface CodeEditorProps {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -54,6 +41,9 @@ interface CodeEditorProps {
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
starter?: string;
|
||||
completions?: Completion[];
|
||||
revealText?: string;
|
||||
revealKey?: number;
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
@@ -69,6 +59,9 @@ export function CodeEditor({
|
||||
required,
|
||||
maxLength,
|
||||
starter,
|
||||
completions = [],
|
||||
revealText,
|
||||
revealKey,
|
||||
}: CodeEditorProps) {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
@@ -94,15 +87,27 @@ export function CodeEditor({
|
||||
return language === "jsx"
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [customJsxCompletionSource] }),
|
||||
autocompletion({ override: [createCustomJsxCompletionSource(completions)] }),
|
||||
customJsxComponentHover,
|
||||
diagnosticsExtension,
|
||||
accessibilityExtension,
|
||||
]
|
||||
: [json(), diagnosticsExtension, accessibilityExtension];
|
||||
}, [diagnostics, id, label, language, t]);
|
||||
}, [completions, diagnostics, id, label, language, t]);
|
||||
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorView || !revealText) return;
|
||||
const from = editorView.state.doc.toString().indexOf(revealText);
|
||||
if (from < 0) return;
|
||||
editorView.dispatch({
|
||||
selection: { anchor: from, head: from + revealText.length },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
editorView.focus();
|
||||
}, [editorView, revealKey, revealText]);
|
||||
|
||||
const formattedValue = useMemo(() => {
|
||||
if (language === "json") {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -12,18 +10,23 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
}
|
||||
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
|
||||
export const CopyAiPromptButton = ({
|
||||
rawResponse,
|
||||
currentConfig,
|
||||
request,
|
||||
documentationUrl,
|
||||
}: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildCustomWidgetAiPrompt(schema, rawResponse, currentConfig));
|
||||
close();
|
||||
await navigator.clipboard.writeText(
|
||||
buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl),
|
||||
);
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
|
||||
@@ -31,34 +34,15 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
|
||||
{!rawResponse && (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
<Button type="button" leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => void handleCopy()}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
@@ -14,6 +14,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -29,6 +30,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
@@ -54,7 +56,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.duplicate"),
|
||||
message: t("notification.duplicated", { name: result.name }),
|
||||
});
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
@@ -77,7 +79,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
@@ -91,34 +93,40 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="bottom-end" shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
|
||||
<IconDots {...iconProps} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
disabled={duplicateMutation.isPending}
|
||||
>
|
||||
{t("action.duplicate")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<>
|
||||
<Menu withinPortal position="bottom-end" shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
|
||||
<IconDots {...iconProps} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
disabled={duplicateMutation.isPending}
|
||||
>
|
||||
{t("action.duplicate")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -133,7 +141,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
|
||||
|
||||
interface AiCardProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
request: string;
|
||||
onRequestChange(value: string): void;
|
||||
documentationUrl: string;
|
||||
onDocumentationUrlChange(value: string): void;
|
||||
onPaste(): void;
|
||||
onCopyDiagnostics(): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.ai");
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("documentationUrl")}
|
||||
value={props.documentationUrl}
|
||||
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
<Group grow wrap="wrap">
|
||||
<CopyAiPromptButton
|
||||
currentConfig={props.candidate}
|
||||
request={props.request}
|
||||
documentationUrl={props.documentationUrl}
|
||||
/>
|
||||
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
|
||||
{t("paste")}
|
||||
</Button>
|
||||
<Button type="button" variant="subtle" leftSection={<IconCopy size={16} />} onClick={props.onCopyDiagnostics}>
|
||||
{t("copyDiagnostics")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
|
||||
import { hoverTooltip } from "@codemirror/view";
|
||||
|
||||
import {
|
||||
customJsxComponentByName,
|
||||
customJsxTablerIconNames,
|
||||
enabledCustomJsxComponents,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
|
||||
label: component.name,
|
||||
type: "class",
|
||||
detail: `${component.category} · ${component.safety}`,
|
||||
info: component.documentationUrl,
|
||||
}));
|
||||
|
||||
const propCompletions: Completion[] = [
|
||||
...new Set(enabledCustomJsxComponents.flatMap((component) => component.supportedProps)),
|
||||
]
|
||||
.filter((prop) => prop !== "key")
|
||||
.map((prop) => ({ label: `${prop}=`, type: "property", detail: "Mantine prop" }));
|
||||
|
||||
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
|
||||
label: `name="${name}"`,
|
||||
type: "enum",
|
||||
detail: "Safe Tabler icon",
|
||||
}));
|
||||
|
||||
export const createCustomJsxCompletionSource =
|
||||
(additional: Completion[]): CompletionSource =>
|
||||
(context) => {
|
||||
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
return {
|
||||
from: word?.from ?? context.pos,
|
||||
options: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
|
||||
};
|
||||
};
|
||||
|
||||
export const customJsxComponentHover = hoverTooltip((view, position) => {
|
||||
const word = view.state.wordAt(position);
|
||||
if (!word) return null;
|
||||
const name = view.state.doc.sliceString(word.from, word.to);
|
||||
const component = customJsxComponentByName.get(name);
|
||||
if (!component || component.safety === "denied") return null;
|
||||
return {
|
||||
pos: word.from,
|
||||
end: word.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.style.padding = "6px 8px";
|
||||
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
|
||||
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
|
||||
const schema = parseJson(values.optionsSchema);
|
||||
const optionKeys = Object.keys(isRecord(schema) && isRecord(schema.properties) ? schema.properties : {});
|
||||
const state = parseJson(values.stateSchema);
|
||||
const stateKeys = Object.keys(isRecord(state) ? state : {});
|
||||
return [
|
||||
...requestIds.flatMap((id) => [
|
||||
{ label: `data["${id}"]`, type: "variable", detail: `Response from ${id}` },
|
||||
{ label: `status["${id}"]`, type: "variable", detail: `Status for ${id}` },
|
||||
{ label: `requestId="${id}"`, type: "property", detail: "Named request" },
|
||||
]),
|
||||
...parseSources(values.sources).map(({ id }) => ({ label: id, type: "constant", detail: "Source ID" })),
|
||||
...optionKeys.map((key) => ({ label: `options.${key}`, type: "variable", detail: "Widget option" })),
|
||||
...stateKeys.map((key) => ({ label: `state.${key}`, type: "variable", detail: "Local state" })),
|
||||
];
|
||||
}
|
||||
|
||||
export function getInvalidCustomWidgetSections(
|
||||
issues: readonly { path: PropertyKey[] }[],
|
||||
requestDiagnostics: EditorDiagnostic[],
|
||||
templateDiagnostics: EditorDiagnostic[],
|
||||
) {
|
||||
const result = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
const field = String(issue.path[0] ?? "");
|
||||
if (["name", "description", "iconUrl"].includes(field)) result.add("general");
|
||||
else if (field === "sources") result.add("sources");
|
||||
else if (field === "requests") result.add("requests");
|
||||
else if (["optionsSchema", "defaultOptions"].includes(field)) result.add("options");
|
||||
else if (["stateSchema", "defaultState"].includes(field)) result.add("state");
|
||||
else if (field === "template") result.add("jsx");
|
||||
}
|
||||
if (requestDiagnostics.some((entry) => entry.severity === "error")) result.add("requests");
|
||||
if (templateDiagnostics.some((entry) => entry.severity === "error")) result.add("jsx");
|
||||
if (result.size > 0) result.add("preview");
|
||||
return result;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
const serverToFormFieldMap: Record<string, Record<string, string>> = {
|
||||
statGrid: { items: "statGridItems" },
|
||||
countGrid: { items: "countGridItems" },
|
||||
statusIndicator: { items: "statusItems" },
|
||||
progressBars: { bars: "progressBars" },
|
||||
keyValue: { mappings: "mappings" },
|
||||
table: { columns: "columns", tablePath: "tablePath" },
|
||||
singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" },
|
||||
raw: { jsonPath: "rawJsonPath" },
|
||||
actionButton: { buttonLabel: "buttonLabel" },
|
||||
customJsx: { template: "template", requests: "requestManifest", networkScope: "networkScope" },
|
||||
};
|
||||
|
||||
export function extractServerErrors(error: unknown, displayType: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const trpcError = error as {
|
||||
data?: { zodError?: { fieldErrors?: Record<string, string[]> } };
|
||||
message?: string;
|
||||
};
|
||||
if (trpcError.data?.zodError?.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(trpcError.data.zodError.fieldErrors)) {
|
||||
if (messages?.[0]) errors[field] = messages[0];
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
try {
|
||||
const issues = JSON.parse(trpcError.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>;
|
||||
const fieldMap = serverToFormFieldMap[displayType] ?? {};
|
||||
for (const issue of issues) {
|
||||
const path = [...issue.path];
|
||||
if (path[0] === "displayConfig") {
|
||||
path.shift();
|
||||
const serverField = String(path[0]);
|
||||
path[0] = fieldMap[serverField] ?? serverField;
|
||||
}
|
||||
errors[path.join(".")] = issue.message;
|
||||
}
|
||||
} catch {
|
||||
// Non-validation server errors are handled by the caller's notification.
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export function EditorSection({
|
||||
id,
|
||||
title,
|
||||
icon: SectionIcon,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: Icon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card id={id} component="section" withBorder p="lg" className={classes.editorSection}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
<SectionIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SaveActions({
|
||||
dirty,
|
||||
pending,
|
||||
invalid,
|
||||
mode,
|
||||
onPreview,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
pending: boolean;
|
||||
invalid: boolean;
|
||||
mode: "create" | "edit";
|
||||
onPreview(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench");
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs">
|
||||
{invalid ? (
|
||||
<IconAlertTriangle size={16} color="var(--mantine-color-red-6)" />
|
||||
) : (
|
||||
<IconCheck size={16} color="var(--mantine-color-green-6)" />
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{dirty ? t("status.unsaved") : invalid ? t("status.invalid") : t("status.ready")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button type="button" variant="light" leftSection={<IconPlayerPlay size={16} />} onClick={onPreview}>
|
||||
{t("action.preview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={pending} disabled={invalid}>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconCode, IconLayoutGrid, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
|
||||
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
import { DISPLAY_TYPE_ICONS, DisplayTypeFields, DisplayTypePicker } from "./_display-type-fields";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface SharedSectionProps extends DisplayFieldsProps {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface FormatSectionProps extends SharedSectionProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function FormatSection({ form, t, sectionRef, onContinue }: FormatSectionProps) {
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="filled" size={40} radius="xl">
|
||||
<IconLayoutGrid size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.format.label")} description={t("steps.format.description")} />
|
||||
<Fieldset legend={t("fieldset.general")}>
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<Fieldset legend={t("field.displayType")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("steps.format.help")}
|
||||
</Text>
|
||||
<DisplayTypePicker
|
||||
value={form.values.displayType}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue("displayType", value);
|
||||
if (value === "customJsx" && !form.values.template.trim())
|
||||
form.setFieldValue("template", CUSTOM_JSX_STARTER);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<ContinueButton label={t("steps.continueTo", { step: t("steps.connection.label") })} onClick={onContinue} />
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionSectionProps extends SharedSectionProps {
|
||||
isTesting: boolean;
|
||||
onTest: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionSection({ form, t, sectionRef, isTesting, onTest, onContinue }: ConnectionSectionProps) {
|
||||
const secretFields = CUSTOM_WIDGET_AUTH_SECRET_FIELDS[form.values.authType] ?? [];
|
||||
const authTypeOptions = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"].map((value) => ({
|
||||
value,
|
||||
label: t(`authType.${value}` as never),
|
||||
}));
|
||||
const handleAuthTypeChange = (value: string) => {
|
||||
form.setFieldValue("authType", value);
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
(CUSTOM_WIDGET_AUTH_SECRET_FIELDS[value] ?? []).map((field) => ({
|
||||
kind: field.kind,
|
||||
value: form.values.secrets.find((secret) => secret.kind === field.kind)?.value ?? "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
|
||||
<IconPlayerPlay size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.connection.label")} description={t("steps.connection.description")} />
|
||||
<Fieldset legend={t("fieldset.connection")}>
|
||||
<Stack gap="sm">
|
||||
<Group align="end" wrap="wrap" gap="xs">
|
||||
<Select
|
||||
label={t("field.method")}
|
||||
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
|
||||
value,
|
||||
label: t(`method.${value}` as never),
|
||||
}))}
|
||||
w={110}
|
||||
{...form.getInputProps("method")}
|
||||
allowDeselect={false}
|
||||
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.url")}
|
||||
required
|
||||
placeholder={t("placeholder.url")}
|
||||
style={{ flex: "1 1 260px" }}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={onTest}
|
||||
loading={isTesting}
|
||||
disabled={!form.values.url || form.values.method !== "GET"}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t("field.authType")}
|
||||
</Text>
|
||||
<Select
|
||||
hiddenFrom="sm"
|
||||
value={form.values.authType}
|
||||
data={authTypeOptions}
|
||||
onChange={(value) => handleAuthTypeChange(value ?? "none")}
|
||||
allowDeselect={false}
|
||||
aria-label={t("field.authType")}
|
||||
/>
|
||||
<SegmentedControl
|
||||
visibleFrom="sm"
|
||||
fullWidth
|
||||
value={form.values.authType}
|
||||
data={authTypeOptions}
|
||||
onChange={handleAuthTypeChange}
|
||||
/>
|
||||
</div>
|
||||
{CUSTOM_WIDGET_AUTH_USES_HEADER_NAME[form.values.authType] && (
|
||||
<TextInput
|
||||
label={t("field.headerName")}
|
||||
placeholder={t("placeholder.headerName")}
|
||||
{...form.getInputProps("headerName")}
|
||||
/>
|
||||
)}
|
||||
{secretFields.map((field) => {
|
||||
const index = form.values.secrets.findIndex((secret) => secret.kind === field.kind);
|
||||
const secret = form.values.secrets[index];
|
||||
if (index === -1 || !secret) return null;
|
||||
const props = {
|
||||
label: t(`secret.${field.labelKey}` as never),
|
||||
placeholder: secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined,
|
||||
...form.getInputProps(`secrets.${index}.value`),
|
||||
};
|
||||
return field.isPassword ? (
|
||||
<PasswordInput key={field.kind} {...props} />
|
||||
) : (
|
||||
<TextInput key={field.kind} {...props} />
|
||||
);
|
||||
})}
|
||||
{form.values.method !== "GET" && (
|
||||
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<ContinueButton label={t("steps.continueTo", { step: t("steps.configure.label") })} onClick={onContinue} />
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigureSection({ form, t, previewJson, sectionRef }: SharedSectionProps) {
|
||||
const SelectedIcon = DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
|
||||
<IconCode size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.configure.label")} description={t("steps.configure.description")} />
|
||||
<Fieldset legend={t("fieldset.display")}>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
{SelectedIcon ? <SelectedIcon size={20} /> : null}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`displayTypeDescription.${form.values.displayType}` as never)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ label, description }: { label: string; description: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="light" size="md" onClick={onClick}>
|
||||
{label}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
|
||||
|
||||
export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
return customWidgetDefinitionSchema.safeParse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: values.name,
|
||||
description: values.description || undefined,
|
||||
iconUrl: values.iconUrl || undefined,
|
||||
sources: parseJson(values.sources),
|
||||
requests: parseJson(values.requests),
|
||||
optionsSchema: parseJson(values.optionsSchema),
|
||||
defaultOptions: parseJson(values.defaultOptions),
|
||||
stateSchema: parseJson(values.stateSchema),
|
||||
defaultState: parseJson(values.defaultState),
|
||||
template: values.template,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
name: widget.name,
|
||||
description: widget.description ?? "",
|
||||
iconUrl: widget.iconUrl ?? "",
|
||||
sources: JSON.stringify(widget.sources, null, 2),
|
||||
requests: JSON.stringify(widget.requests, null, 2),
|
||||
optionsSchema: JSON.stringify(widget.optionsSchema, null, 2),
|
||||
defaultOptions: JSON.stringify(widget.defaultOptions, null, 2),
|
||||
stateSchema: JSON.stringify(widget.stateSchema ?? {}, null, 2),
|
||||
defaultState: JSON.stringify(widget.defaultState ?? {}, null, 2),
|
||||
template: widget.template,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSources(value: string): CustomWidgetSource[] {
|
||||
const parsed = parseJson(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((entry): entry is CustomWidgetSource => isRecord(entry) && typeof entry.id === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
export function parseJsonArray(value: string): unknown[] {
|
||||
const parsed = parseJson(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
export function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isRuntimeParams(
|
||||
value: unknown,
|
||||
schema: Record<string, "string" | "number" | "boolean">,
|
||||
): value is Record<string, string | number | boolean> {
|
||||
if (!isRecord(value)) return false;
|
||||
return Object.entries(schema).every(([name, type]) => typeof value[name] === type);
|
||||
}
|
||||
|
||||
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
|
||||
const data: Record<string, unknown> = {};
|
||||
const status: Record<string, unknown> = {};
|
||||
for (const request of definition.requests.filter((entry) => entry.kind === "query" && entry.trigger === "load")) {
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(request.parameters).flatMap(([name, type]) => {
|
||||
const value = definition.defaultOptions[name];
|
||||
return typeof value === type ? [[name, value as string | number | boolean]] : [];
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId: request.id, params });
|
||||
data[request.id] = result.data;
|
||||
status[request.id] = { loading: false, ...result };
|
||||
} catch (error) {
|
||||
data[request.id] = null;
|
||||
status[request.id] = {
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
return { data, status };
|
||||
}
|
||||
@@ -2,6 +2,34 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionNav {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
padding: 0.35rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.editorSection {
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: var(--mantine-spacing-xl);
|
||||
@@ -48,78 +76,6 @@
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.formFlow {
|
||||
--flow-marker-size: 2.5rem;
|
||||
}
|
||||
|
||||
.formSection {
|
||||
position: relative;
|
||||
padding: 0 0 var(--mantine-spacing-xl) calc(var(--flow-marker-size) + var(--mantine-spacing-lg));
|
||||
scroll-margin-top: 5rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.formSection:not(:last-child)::before {
|
||||
position: absolute;
|
||||
top: var(--flow-marker-size);
|
||||
bottom: 0;
|
||||
left: calc(var(--flow-marker-size) / 2);
|
||||
width: 1px;
|
||||
background: var(--mantine-color-default-border);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.formSection:focus-visible {
|
||||
border-radius: var(--mantine-radius-md);
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.sectionMarker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Box, Button, Group, Paper, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Button, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconEye,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { CustomWidgetAuthType, CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
|
||||
import { buildDisplayConfigFromFormValues, CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeRequestManifest,
|
||||
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
customWidgetFormSchema,
|
||||
parseRequestManifest,
|
||||
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
renameCustomWidgetRequest,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
|
||||
import { CustomWidgetPreview } from "./_custom-widget-preview";
|
||||
import { ConfigureSection, ConnectionSection, FormatSection } from "./_custom-widget-form-sections";
|
||||
import { extractServerErrors } from "./_custom-widget-form-errors";
|
||||
import { useCustomWidgetClipboard } from "./_use-custom-widget-clipboard";
|
||||
import { useCustomWidgetPreview } from "./_use-custom-widget-preview";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
|
||||
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
|
||||
import { applyDefinition, buildDefinition, isRecord, parseJson, parseJsonArray } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { CustomWidgetRequestTools } from "./_custom-widget-request-tools";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface CustomWidgetFormProps {
|
||||
@@ -32,260 +42,254 @@ interface CustomWidgetFormProps {
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
const sectionLinks = [
|
||||
["general", "section.general", IconSettings],
|
||||
["sources", "section.sources", IconApi],
|
||||
["requests", "section.requests", IconDatabase],
|
||||
["options", "section.options", IconBraces],
|
||||
["state", "section.state", IconSettings],
|
||||
["jsx", "section.jsx", IconCode],
|
||||
["preview", "section.preview", IconEye],
|
||||
] as const;
|
||||
|
||||
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
|
||||
const router = useRouter();
|
||||
const globalT = useI18n();
|
||||
const t = useScopedI18n("customWidget");
|
||||
const utils = clientApi.useUtils();
|
||||
const createMutation = clientApi.customWidget.create.useMutation();
|
||||
const updateMutation = clientApi.customWidget.update.useMutation();
|
||||
const [previewRefreshSignal, setPreviewRefreshSignal] = useState(0);
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
const formatSectionRef = useRef<HTMLElement>(null);
|
||||
const connectionSectionRef = useRef<HTMLElement>(null);
|
||||
const configureSectionRef = useRef<HTMLElement>(null);
|
||||
const scrollToSection = useCallback((section: number) => {
|
||||
const element = [formatSectionRef.current, connectionSectionRef.current, configureSectionRef.current][section];
|
||||
if (!element) return;
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.setTimeout(() => element.focus({ preventScroll: true }), 250);
|
||||
}, []);
|
||||
|
||||
const mergedInitialValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
|
||||
if (mergedInitialValues.displayType === "customJsx" && !mergedInitialValues.template.trim()) {
|
||||
mergedInitialValues.template = CUSTOM_JSX_STARTER;
|
||||
}
|
||||
const form = useZodForm(customWidgetFormSchema, { initialValues: mergedInitialValues });
|
||||
const preview = useCustomWidgetPreview({
|
||||
form,
|
||||
definitionId,
|
||||
refreshSignal: previewRefreshSignal,
|
||||
onOpenPreview: () => setMobilePane("preview"),
|
||||
t,
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const form = useZodForm(customWidgetFormSchema, {
|
||||
initialValues: { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues },
|
||||
});
|
||||
useCustomWidgetClipboard({ enabled: mode === "edit", form, t, onReplace: preview.reset });
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
const [request, setRequest] = useState("");
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null });
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
|
||||
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>({});
|
||||
const [stateSnapshot, setStateSnapshot] = useState<Record<string, unknown>>({});
|
||||
const [revealedRequest, setRevealedRequest] = useState({ text: "", key: 0 });
|
||||
const dirtyRef = useRef(false);
|
||||
dirtyRef.current = form.isDirty();
|
||||
|
||||
const requestManifest = parseRequestManifest(form.values.requestManifest);
|
||||
const requestIds = requestManifest
|
||||
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
const editorDiagnostics =
|
||||
form.values.displayType === "customJsx"
|
||||
? analyzeJsxTemplate(form.values.template, {
|
||||
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
|
||||
requestIds,
|
||||
})
|
||||
: [];
|
||||
const requestDiagnostics =
|
||||
form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"
|
||||
? analyzeRequestManifest(form.values.requestManifest)
|
||||
: [];
|
||||
const hasEditorErrors = [...editorDiagnostics, ...requestDiagnostics].some(
|
||||
(diagnostic) => diagnostic.severity === "error",
|
||||
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
|
||||
const requestIds = useMemo(
|
||||
() =>
|
||||
parseJsonArray(form.values.requests).flatMap((entry) =>
|
||||
isRecord(entry) && typeof entry.id === "string" ? [entry.id] : [],
|
||||
),
|
||||
[form.values.requests],
|
||||
);
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
const stateKeys = useMemo(() => {
|
||||
const state = parseJson(form.values.stateSchema);
|
||||
return Object.keys(isRecord(state) ? state : {});
|
||||
}, [form.values.stateSchema]);
|
||||
const jsxCompletions = useMemo(
|
||||
() => createCustomWidgetCompletions(form.values, requestIds),
|
||||
[form.values, requestIds],
|
||||
);
|
||||
const templateDiagnostics = useMemo(
|
||||
() => analyzeJsxTemplate(form.values.template, { requestIds, stateKeys }),
|
||||
[form.values.template, requestIds, stateKeys],
|
||||
);
|
||||
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
|
||||
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
|
||||
const invalidSections = useMemo(
|
||||
() =>
|
||||
getInvalidCustomWidgetSections(
|
||||
candidate.success ? [] : candidate.error.issues,
|
||||
requestDiagnostics,
|
||||
templateDiagnostics,
|
||||
),
|
||||
[candidate, requestDiagnostics, templateDiagnostics],
|
||||
);
|
||||
|
||||
const { save, runPreview, pasteAiResponse, copyDiagnostics, pending } = useCustomWidgetFormActions({
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
candidate,
|
||||
templateDiagnostics,
|
||||
requestDiagnostics,
|
||||
preview,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
setOptionsSnapshot,
|
||||
setStateSnapshot,
|
||||
request,
|
||||
documentationUrl,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (dirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", beforeUnload);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2" && form.values.method !== "GET") {
|
||||
form.setFieldValue("method", "GET");
|
||||
form.setFieldValue("requestBody", "");
|
||||
}
|
||||
}, [form, form.values.displayType, form.values.jsxApiVersion, form.values.method]);
|
||||
|
||||
const focusStepForErrors = (errors: Record<string, unknown>) => {
|
||||
const fields = Object.keys(errors);
|
||||
if (fields.some((field) => ["name", "description", "iconUrl", "displayType"].includes(field.split(".")[0] ?? ""))) {
|
||||
scrollToSection(0);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
fields.some((field) =>
|
||||
["url", "method", "authType", "headerName", "requestBody", "secrets"].includes(field.split(".")[0] ?? ""),
|
||||
)
|
||||
) {
|
||||
scrollToSection(1);
|
||||
return;
|
||||
}
|
||||
scrollToSection(2);
|
||||
const invalid = hasDiagnostics || !candidate.success;
|
||||
const renameRequest = (currentId: string, nextId: string) => {
|
||||
if (!candidate.success) throw new Error(w("invalidWidget"));
|
||||
applyDefinition(form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
|
||||
setRevealedRequest((current) => ({ text: `"id": "${nextId}"`, key: current.key + 1 }));
|
||||
};
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
if (hasEditorErrors) return;
|
||||
const displayConfig = buildDisplayConfigFromFormValues(values);
|
||||
|
||||
const payload = {
|
||||
name: values.name,
|
||||
description: values.description || undefined,
|
||||
iconUrl: values.iconUrl || undefined,
|
||||
url: values.url,
|
||||
authType: values.authType as CustomWidgetAuthType,
|
||||
headerName: values.headerName || undefined,
|
||||
method: values.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
requestBody: values.requestBody || undefined,
|
||||
displayType: values.displayType as CustomWidgetDisplayType,
|
||||
displayConfig: displayConfig as never,
|
||||
secrets: values.secrets
|
||||
.filter((s) => s.value)
|
||||
.map((s) => ({
|
||||
kind: s.kind as "apiKey" | "username" | "password",
|
||||
value: s.value,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
showSuccessNotification({
|
||||
title: t("action.create"),
|
||||
message: t("notification.created", { name: values.name }),
|
||||
});
|
||||
await utils.customWidget.all.invalidate();
|
||||
router.push(`/manage/custom-widgets/edit/${result.id}`);
|
||||
} else if (definitionId) {
|
||||
await updateMutation.mutateAsync({ id: definitionId, ...payload });
|
||||
showSuccessNotification({
|
||||
title: t("action.save"),
|
||||
message: t("notification.updated", { name: values.name }),
|
||||
});
|
||||
setPreviewRefreshSignal((n) => n + 1);
|
||||
await utils.customWidget.all.invalidate();
|
||||
await utils.customWidget.byId.invalidate({ id: definitionId });
|
||||
await utils.widget.customApi.getData.invalidate();
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
}
|
||||
} catch (err) {
|
||||
const serverErrors = extractServerErrors(err, values.displayType);
|
||||
if (Object.keys(serverErrors).length > 0) {
|
||||
form.setErrors(serverErrors);
|
||||
focusStepForErrors(serverErrors);
|
||||
}
|
||||
const errorKey = mode === "create" ? "notification.createError" : "notification.updateError";
|
||||
showErrorNotification({
|
||||
title: t("action.save"),
|
||||
message: t(errorKey as never),
|
||||
});
|
||||
}
|
||||
}, focusStepForErrors);
|
||||
|
||||
const continueToSection = (currentSection: number, field: string, nextSection: number) => {
|
||||
if (form.validateField(field).hasError) {
|
||||
scrollToSection(currentSection);
|
||||
return;
|
||||
}
|
||||
scrollToSection(nextSection);
|
||||
const revealRequest = (requestId: string) => {
|
||||
setRevealedRequest((current) => ({ text: `"id": "${requestId}"`, key: current.key + 1 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classes.form}>
|
||||
<form onSubmit={save} className={classes.form}>
|
||||
<SegmentedControl
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={mobilePane}
|
||||
onChange={(value) => setMobilePane(value as "configure" | "preview")}
|
||||
onChange={(value) => setMobilePane(value as typeof mobilePane)}
|
||||
data={[
|
||||
{ value: "configure", label: t("workbench.configure") },
|
||||
{ value: "preview", label: t("workbench.preview") },
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
]}
|
||||
/>
|
||||
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
|
||||
{sectionLinks.map(([id, key, SectionIcon]) => (
|
||||
<Button
|
||||
key={id}
|
||||
component="a"
|
||||
href={`#${id}`}
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color={invalidSections.has(id) ? "red" : undefined}
|
||||
leftSection={<SectionIcon size={14} />}
|
||||
rightSection={
|
||||
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
|
||||
}
|
||||
>
|
||||
{w(key)}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={classes.workbench} data-mobile-pane={mobilePane}>
|
||||
<Stack gap="xl" className={classes.configuration}>
|
||||
<Stack gap={0} className={classes.formFlow}>
|
||||
<FormatSection
|
||||
form={form}
|
||||
t={t}
|
||||
previewJson={preview.json}
|
||||
sectionRef={formatSectionRef}
|
||||
onContinue={() => continueToSection(0, "name", 1)}
|
||||
<Stack gap="lg" className={classes.configuration}>
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
</EditorSection>
|
||||
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
|
||||
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
|
||||
</EditorSection>
|
||||
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
|
||||
<CustomWidgetRequestTools requestIds={requestIds} onGoTo={revealRequest} onRename={renameRequest} />
|
||||
<CodeEditor
|
||||
id="requests-editor"
|
||||
label={w("requests.label")}
|
||||
description={w("requests.description")}
|
||||
language="json"
|
||||
value={form.values.requests}
|
||||
onChange={(value) => form.setFieldValue("requests", value)}
|
||||
diagnostics={requestDiagnostics}
|
||||
error={form.errors.requests}
|
||||
revealText={revealedRequest.text}
|
||||
revealKey={revealedRequest.key}
|
||||
required
|
||||
/>
|
||||
<ConnectionSection
|
||||
form={form}
|
||||
t={t}
|
||||
previewJson={preview.json}
|
||||
sectionRef={connectionSectionRef}
|
||||
isTesting={preview.isTesting}
|
||||
onTest={() => void preview.test()}
|
||||
onContinue={() => continueToSection(1, "url", 2)}
|
||||
</EditorSection>
|
||||
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
|
||||
<CodeEditor
|
||||
id="options-schema"
|
||||
label={w("options.schema")}
|
||||
language="json"
|
||||
value={form.values.optionsSchema}
|
||||
onChange={(value) => form.setFieldValue("optionsSchema", value)}
|
||||
error={form.errors.optionsSchema}
|
||||
/>
|
||||
<ConfigureSection form={form} t={t} previewJson={preview.json} sectionRef={configureSectionRef} />
|
||||
</Stack>
|
||||
<CodeEditor
|
||||
id="default-options"
|
||||
label={w("options.defaults")}
|
||||
language="json"
|
||||
value={form.values.defaultOptions}
|
||||
onChange={(value) => form.setFieldValue("defaultOptions", value)}
|
||||
error={form.errors.defaultOptions}
|
||||
/>
|
||||
</EditorSection>
|
||||
<EditorSection id="state" title={w("state.title")} icon={IconSettings}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{w("state.description")}
|
||||
</Text>
|
||||
<CodeEditor
|
||||
id="state-schema"
|
||||
label={w("state.schema")}
|
||||
language="json"
|
||||
value={form.values.stateSchema}
|
||||
onChange={(value) => form.setFieldValue("stateSchema", value)}
|
||||
error={form.errors.stateSchema}
|
||||
/>
|
||||
<CodeEditor
|
||||
id="default-state"
|
||||
label={w("state.defaults")}
|
||||
language="json"
|
||||
value={form.values.defaultState}
|
||||
onChange={(value) => form.setFieldValue("defaultState", value)}
|
||||
error={form.errors.defaultState}
|
||||
/>
|
||||
</EditorSection>
|
||||
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
|
||||
<CodeEditor
|
||||
id="jsx-editor"
|
||||
label={w("jsx.label")}
|
||||
language="jsx"
|
||||
value={form.values.template}
|
||||
onChange={(value) => form.setFieldValue("template", value)}
|
||||
diagnostics={templateDiagnostics}
|
||||
completions={jsxCompletions}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={50_000}
|
||||
/>
|
||||
</EditorSection>
|
||||
<CustomWidgetAiCard
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
request={request}
|
||||
onRequestChange={setRequest}
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={() => void pasteAiResponse()}
|
||||
onCopyDiagnostics={() => void copyDiagnostics()}
|
||||
/>
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{form.isDirty() ? globalT("common.unsavedChanges") : t("action.readyToSave")}
|
||||
</Text>
|
||||
<Button
|
||||
type="submit"
|
||||
size="md"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
disabled={hasEditorErrors}
|
||||
>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
pending={pending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Box component="aside" className={classes.previewPane} aria-label={t("preview.title")}>
|
||||
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
|
||||
<Paper p="md" className={classes.actionBar} shadow="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{form.isDirty() ? globalT("common.unsavedChanges") : t("action.readyToSave")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" visibleFrom="sm">
|
||||
{hasEditorErrors ? t("editor.saveBlocked") : t("editor.saveReady")}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="md"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
disabled={hasEditorErrors}
|
||||
miw={160}
|
||||
>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
pending={pending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
/>
|
||||
</Paper>
|
||||
<CopyAiPromptButton
|
||||
rawResponse={preview.fetchResult?.rawResponse}
|
||||
currentConfig={{
|
||||
$schema: "homarr-custom-widget-v3",
|
||||
name: form.values.name,
|
||||
description: form.values.description,
|
||||
iconUrl: form.values.iconUrl,
|
||||
url: form.values.url,
|
||||
authType: form.values.authType,
|
||||
headerName: form.values.headerName,
|
||||
method: form.values.method,
|
||||
requestBody: form.values.requestBody,
|
||||
displayType: form.values.displayType,
|
||||
displayConfig: preview.input.displayConfig,
|
||||
}}
|
||||
/>
|
||||
<CustomWidgetPreview
|
||||
getFormValues={preview.getInput}
|
||||
formValues={preview.input}
|
||||
fetchResult={preview.fetchResult}
|
||||
cachedJson={preview.json}
|
||||
onTest={() => void preview.test()}
|
||||
isTesting={preview.isTesting}
|
||||
isSampleStale={preview.isStale}
|
||||
testError={preview.testError ? t("notification.previewError") : null}
|
||||
onInsertDataPath={form.values.displayType === "customJsx" ? preview.insertDataPath : undefined}
|
||||
onSetPreviewLiveActions={preview.setLiveActions}
|
||||
isUpdatingPreviewActions={preview.isUpdatingLiveActions}
|
||||
onSampleDataChange={preview.setSampleData}
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
preview={preview}
|
||||
size={previewSize}
|
||||
onSizeChange={setPreviewSize}
|
||||
theme={previewTheme}
|
||||
onThemeChange={setPreviewTheme}
|
||||
optionsSnapshot={optionsSnapshot}
|
||||
onOptionsChange={setOptionsSnapshot}
|
||||
stateSnapshot={stateSnapshot}
|
||||
onStateChange={setStateSnapshot}
|
||||
onLiveActionsChange={(enabled) =>
|
||||
setPreview((current) => ({
|
||||
...current,
|
||||
session: current.session ? { ...current.session, liveActions: enabled } : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -13,21 +13,8 @@ import { Link } from "@homarr/ui";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
|
||||
const displayTypeBadgeColors: Record<string, string> = {
|
||||
singleValue: "blue",
|
||||
keyValue: "green",
|
||||
table: "orange",
|
||||
statGrid: "violet",
|
||||
progressBars: "teal",
|
||||
statusIndicator: "cyan",
|
||||
countGrid: "indigo",
|
||||
raw: "gray",
|
||||
actionButton: "red",
|
||||
customJsx: "pink",
|
||||
};
|
||||
|
||||
interface CustomWidgetListProps {
|
||||
definitions: RouterOutputs["customWidget"]["all"];
|
||||
definitions: RouterOutputs["customWidget"]["list"];
|
||||
}
|
||||
|
||||
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
@@ -52,7 +39,7 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetDef = RouterOutputs["customWidget"]["all"][number];
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
@@ -64,7 +51,7 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
@@ -99,19 +86,14 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
{widget.url && (
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.url}
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge
|
||||
color={displayTypeBadgeColors[widget.displayType] ?? "gray"}
|
||||
size="sm"
|
||||
variant="light"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t(`displayType.${widget.displayType}` as never)}
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Fieldset, Stack, Textarea } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
export function PreviewActionControl({
|
||||
request,
|
||||
sessionId,
|
||||
options,
|
||||
state,
|
||||
}: {
|
||||
request: HomarrCustomWidgetV2["requests"][number];
|
||||
sessionId?: string;
|
||||
options: Record<string, unknown>;
|
||||
state: Record<string, unknown>;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const actionMutation = clientApi.customWidget.previewAction.useMutation();
|
||||
const initialParams = Object.fromEntries(
|
||||
Object.entries(request.parameters).map(([name, type]) => {
|
||||
const bound = state[name] ?? options[name];
|
||||
if (typeof bound === type) return [name, bound];
|
||||
return [name, type === "number" ? 0 : type === "boolean" ? false : ""];
|
||||
}),
|
||||
);
|
||||
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
|
||||
const parsed = parseJson(params);
|
||||
const validParams = isRuntimeParams(parsed, request.parameters) ? parsed : null;
|
||||
const run = async () => {
|
||||
if (!sessionId || !validParams) return;
|
||||
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
|
||||
if (needsConfirmation) {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
openConfirmModal({
|
||||
title: request.confirmation?.title ?? request.id,
|
||||
children: request.confirmation?.message ?? t("deleteConfirmation"),
|
||||
labels: { confirm: request.confirmation?.confirmLabel },
|
||||
confirmProps: { color: request.confirmation?.destructive || request.method === "DELETE" ? "red.9" : "blue" },
|
||||
onConfirm: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
actionMutation.mutate(
|
||||
{ sessionId, requestId: request.id, params: validParams, confirmed: needsConfirmation },
|
||||
{
|
||||
onSuccess: (result) =>
|
||||
showSuccessNotification({
|
||||
title: request.id,
|
||||
message: result.simulated ? t("simulated") : t("httpStatus", { status: result.status }),
|
||||
}),
|
||||
onError: (error) => showErrorNotification({ title: request.id, message: error.message }),
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Fieldset legend={`${request.id} · ${request.method}`}>
|
||||
<Stack gap="xs">
|
||||
{Object.keys(request.parameters).length > 0 && (
|
||||
<Textarea
|
||||
label={t("actionParameters")}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={params}
|
||||
error={validParams ? undefined : t("invalidParameters")}
|
||||
onChange={(event) => setParams(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
color={request.confirmation?.destructive || request.method === "DELETE" ? "red" : undefined}
|
||||
disabled={!sessionId || !validParams}
|
||||
loading={actionMutation.isPending}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{request.confirmation?.confirmLabel ??
|
||||
(request.confirmation?.destructive || request.method === "DELETE" ? t("runDestructive") : t("runAction"))}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
MantineProvider,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { analyzeCustomWidgetAccessibility } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import { PreviewActionControl } from "./_custom-widget-preview-action";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export interface PreviewState {
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
session: { id: string; expiresAt: number; liveActions: boolean } | null;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(value: string): void;
|
||||
theme: "light" | "dark";
|
||||
onThemeChange(value: "light" | "dark"): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange(value: Record<string, unknown>): void;
|
||||
stateSnapshot: Record<string, unknown>;
|
||||
onStateChange(value: Record<string, unknown>): void;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: props.preview.session?.id ?? "" },
|
||||
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
|
||||
);
|
||||
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
|
||||
if (!props.candidate) return <Alert color="yellow">{t("invalid")}</Alert>;
|
||||
const candidate = props.candidate;
|
||||
const accessibilityIssues = analyzeCustomWidgetAccessibility(candidate.template);
|
||||
const fixtureData =
|
||||
fixture === "empty" ? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []])) : props.preview.data;
|
||||
const fixtureStatus =
|
||||
fixture === "loading"
|
||||
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }]))
|
||||
: fixture === "error"
|
||||
? Object.fromEntries(
|
||||
candidate.requests.map((entry) => [entry.id, { loading: false, ok: false, error: t("fixtureError") }]),
|
||||
)
|
||||
: props.preview.status;
|
||||
const displayData = {
|
||||
template: candidate.template,
|
||||
data: fixtureData,
|
||||
status: fixtureStatus,
|
||||
options: props.optionsSnapshot,
|
||||
stateSchema: candidate.stateSchema ?? {},
|
||||
defaultState: props.stateSnapshot,
|
||||
requestCapabilities: candidate.requests.map(
|
||||
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission,
|
||||
confirmation,
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
previewSessionId: props.preview.session?.id,
|
||||
previewLiveActions: props.preview.session?.liveActions ?? false,
|
||||
queriesDisabled: fixture !== "live",
|
||||
isEditMode: fixture !== "live",
|
||||
onStateChange: props.onStateChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.size}
|
||||
onChange={props.onSizeChange}
|
||||
data={[
|
||||
{ value: "compact", label: t("size.compact") },
|
||||
{ value: "standard", label: t("size.standard") },
|
||||
{ value: "wide", label: t("size.wide") },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.theme}
|
||||
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
|
||||
data={[
|
||||
{ value: "light", label: t("theme.light") },
|
||||
{ value: "dark", label: t("theme.dark") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fixture}
|
||||
onChange={(value) => setFixture(value as typeof fixture)}
|
||||
data={[
|
||||
{ value: "live", label: t("fixture.live") },
|
||||
{ value: "loading", label: t("fixture.loading") },
|
||||
{ value: "empty", label: t("fixture.empty") },
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="widget" keepMounted={false}>
|
||||
<Tabs.List grow>
|
||||
{(["widget", "data", "options", "state", "actions", "diagnostics"] as const).map((tab) => (
|
||||
<Tabs.Tab key={tab} value={tab}>
|
||||
{t(`tab.${tab}`)}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Box className={classes.previewCanvas}>
|
||||
<MantineProvider forceColorScheme={props.theme}>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</Paper>
|
||||
</MantineProvider>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="data" pt="sm">
|
||||
<CodeEditor
|
||||
id="preview-data"
|
||||
label={t("requestData")}
|
||||
language="json"
|
||||
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="options" pt="sm">
|
||||
<JsonPreviewEditor
|
||||
id="preview-options"
|
||||
label={t("instanceOptions")}
|
||||
value={props.optionsSnapshot}
|
||||
onChange={props.onOptionsChange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="state" pt="sm">
|
||||
<JsonPreviewEditor
|
||||
id="preview-state"
|
||||
label={t("localState")}
|
||||
value={props.stateSnapshot}
|
||||
onChange={props.onStateChange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="actions" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
label={t("liveActions")}
|
||||
description={t("liveActionsDescription")}
|
||||
checked={props.preview.session?.liveActions ?? false}
|
||||
disabled={!props.preview.session || liveActionsMutation.isPending}
|
||||
onChange={(event) => {
|
||||
if (!props.preview.session) return;
|
||||
const enabled = event.currentTarget.checked;
|
||||
liveActionsMutation.mutate(
|
||||
{ sessionId: props.preview.session.id, enabled },
|
||||
{
|
||||
onSuccess: () => props.onLiveActionsChange(enabled),
|
||||
onError: (error) => showErrorNotification({ title: t("liveActions"), message: error.message }),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{candidate.requests
|
||||
.filter((request) => request.kind === "action")
|
||||
.map((request) => (
|
||||
<PreviewActionControl
|
||||
key={request.id}
|
||||
request={request}
|
||||
sessionId={props.preview.session?.id}
|
||||
options={props.optionsSnapshot}
|
||||
state={props.stateSnapshot}
|
||||
/>
|
||||
))}
|
||||
{candidate.requests.every((request) => request.kind !== "action") && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noActions")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
{props.preview.session ? (
|
||||
<Badge color="green">{t("sessionActive")}</Badge>
|
||||
) : (
|
||||
<Badge color="gray">{t("simulationPending")}</Badge>
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("containedFailures")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("accessibility.title")}
|
||||
</Text>
|
||||
{accessibilityIssues.length === 0 ? (
|
||||
<Alert color="green">{t("accessibility.ready")}</Alert>
|
||||
) : (
|
||||
accessibilityIssues.map((issue) => (
|
||||
<Alert key={issue} color="yellow">
|
||||
{t(`accessibility.${issue}`)}
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
{props.preview.session && (
|
||||
<CodeEditor
|
||||
id="preview-journal"
|
||||
label={t("journal")}
|
||||
language="json"
|
||||
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonPreviewEditor({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: Record<string, unknown>;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<CodeEditor
|
||||
id={id}
|
||||
label={label}
|
||||
language="json"
|
||||
value={draft}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
const parsed = parseJson(next);
|
||||
if (isRecord(parsed)) onChange(parsed);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Card, Stack, Tabs } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
DisplayConfig,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { extractDisplayData } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
getPreviewLegacyMethods,
|
||||
getPreviewNamedRequests,
|
||||
PreviewDiagnosticsPanel,
|
||||
PreviewErrorBoundary,
|
||||
PreviewHeader,
|
||||
PreviewRequestPanel,
|
||||
PreviewResponsePanel,
|
||||
redactPreviewUrl,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { PreviewWidgetPanel } from "./_preview-widget-panel";
|
||||
|
||||
export interface PreviewInput {
|
||||
url: string;
|
||||
method: CustomWidgetMethod;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
requestBody?: string;
|
||||
displayType: CustomWidgetDisplayType;
|
||||
displayConfig: DisplayConfig;
|
||||
secrets: Array<{ kind: CustomWidgetSecretKind; value: string }>;
|
||||
definitionId?: 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 {
|
||||
getFormValues: () => PreviewInput;
|
||||
formValues: PreviewInput;
|
||||
fetchResult: PreviewFetchResult | null;
|
||||
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;
|
||||
}
|
||||
|
||||
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
|
||||
|
||||
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 displayConfig = formValues.displayConfig as DisplayConfig & Record<string, unknown>;
|
||||
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 } = formValues;
|
||||
if (displayType === "actionButton") {
|
||||
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
|
||||
}
|
||||
if (!fetchResult?.success || cachedJson == null) return null;
|
||||
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, displayConfig]);
|
||||
|
||||
const namedRequests = useMemo(() => getPreviewNamedRequests(displayConfig), [displayConfig]);
|
||||
const httpMethods = useMemo(() => {
|
||||
if (formValues.displayType !== "customJsx") return [];
|
||||
if (namedRequests.length > 0) return [...new Set(namedRequests.map((request) => request.method))];
|
||||
return getPreviewLegacyMethods((displayConfig.template as string) ?? "");
|
||||
}, [formValues.displayType, displayConfig.template, namedRequests]);
|
||||
const hasNamedActions = namedRequests.some((request) => request.kind === "action");
|
||||
const templateDiagnostics = useMemo(
|
||||
() =>
|
||||
formValues.displayType === "customJsx"
|
||||
? analyzeJsxTemplate((displayConfig.template as string) ?? "", {
|
||||
apiVersion: displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestIds: namedRequests.map((request) => request.id),
|
||||
})
|
||||
: [],
|
||||
[displayConfig, formValues.displayType, namedRequests],
|
||||
);
|
||||
|
||||
const handleTest = () => {
|
||||
const values = getFormValues();
|
||||
if (!values.url || values.method !== "GET") return;
|
||||
onTest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<PreviewHeader
|
||||
method={formValues.method}
|
||||
url={formValues.url}
|
||||
methods={httpMethods}
|
||||
hasNamedActions={hasNamedActions}
|
||||
hasPreviewSession={Boolean(fetchResult?.previewSession)}
|
||||
liveActions={fetchResult?.previewSession?.liveActions ?? false}
|
||||
isUpdatingLiveActions={isUpdatingPreviewActions}
|
||||
isTesting={isTesting}
|
||||
isSampleStale={isSampleStale}
|
||||
testError={testError}
|
||||
responseError={fetchResult?.success === false ? fetchResult.error : undefined}
|
||||
responseStatus={fetchResult?.success === false ? fetchResult.responseInfo : undefined}
|
||||
onTest={handleTest}
|
||||
onSetLiveActions={onSetPreviewLiveActions}
|
||||
methodColor={methodColor}
|
||||
messages={{
|
||||
title: t("preview.title"),
|
||||
interactive: t("preview.capabilities.interactive"),
|
||||
capabilitiesTitle: t("preview.capabilities.title"),
|
||||
capabilitiesDescription: t("preview.capabilities.description"),
|
||||
liveActions: t("preview.capabilities.liveActions"),
|
||||
liveActionsDescription: t("preview.capabilities.liveActionsDescription"),
|
||||
runTestFirst: t("preview.capabilities.runTestFirst"),
|
||||
simulated: t("preview.capabilities.simulated"),
|
||||
test: t("preview.test"),
|
||||
mutationDisabled: t("preview.mutationDisabled"),
|
||||
staleTitle: t("preview.stale.title"),
|
||||
staleDescription: t("preview.stale.description"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<PreviewWidgetPanel
|
||||
data={displayData}
|
||||
displayType={formValues.displayType}
|
||||
success={fetchResult?.success ?? false}
|
||||
responseInfo={fetchResult?.responseInfo ?? null}
|
||||
previewSize={previewSize}
|
||||
onPreviewSizeChange={setPreviewSize}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="response" pt="sm">
|
||||
<PreviewResponsePanel
|
||||
value={cachedJson}
|
||||
rawResponse={fetchResult?.rawResponse}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
onSampleDataChange={onSampleDataChange}
|
||||
messages={{
|
||||
empty: t("preview.response.empty"),
|
||||
sampleHint: t("preview.response.sampleHint"),
|
||||
editSample: t("preview.response.editSample"),
|
||||
addSample: t("preview.response.addSample"),
|
||||
copied: t("preview.response.copied"),
|
||||
copy: t("preview.response.copy"),
|
||||
sampleLabel: t("preview.response.sampleLabel"),
|
||||
sampleDescription: t("preview.response.sampleDescription"),
|
||||
invalidSample: t("preview.response.invalidSample"),
|
||||
cancelSample: t("preview.response.cancelSample"),
|
||||
applySample: t("preview.response.applySample"),
|
||||
copyPath: t("preview.response.copyPath"),
|
||||
pathCopied: t("preview.response.pathCopied"),
|
||||
insertPath: t("preview.response.insertPath"),
|
||||
openRaw: t("preview.rawResponse"),
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="request" pt="sm">
|
||||
<PreviewRequestPanel
|
||||
method={formValues.method}
|
||||
authentication={formValues.authType}
|
||||
endpoint={redactPreviewUrl(formValues.url)}
|
||||
status={fetchResult?.responseInfo?.status}
|
||||
namedRequests={namedRequests}
|
||||
journal={journalQuery.data}
|
||||
methodColor={methodColor}
|
||||
messages={{
|
||||
method: t("preview.request.method"),
|
||||
authentication: t("preview.request.authentication"),
|
||||
endpoint: t("preview.request.endpoint"),
|
||||
status: t("preview.request.status"),
|
||||
notRun: t("preview.request.notRun"),
|
||||
named: t("preview.request.named", { count: namedRequests.length }),
|
||||
journal: t("preview.request.journal"),
|
||||
journalEmpty: t("preview.request.journalEmpty"),
|
||||
simulated: t("preview.request.simulated"),
|
||||
redacted: t("preview.request.redacted"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
duration: (duration) => t("preview.request.duration", { duration }),
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<PreviewDiagnosticsPanel
|
||||
templateLength={(displayConfig.template as string | undefined)?.length ?? 0}
|
||||
namedRequestCount={namedRequests.length}
|
||||
methods={httpMethods.join(", ") || "GET"}
|
||||
networkScope={(displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")}
|
||||
diagnostics={templateDiagnostics}
|
||||
messages={{
|
||||
templateSize: t("preview.diagnostics.templateSize"),
|
||||
characters: (count) => t("preview.diagnostics.characters", { count }),
|
||||
namedRequests: t("preview.diagnostics.namedRequests"),
|
||||
methods: t("preview.diagnostics.methods"),
|
||||
networkScope: t("preview.diagnostics.networkScope"),
|
||||
ready: t("preview.diagnostics.ready"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}${editorT(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Group, Select, TextInput } from "@mantine/core";
|
||||
import { IconEdit, IconTargetArrow } from "@tabler/icons-react";
|
||||
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export function CustomWidgetRequestTools({
|
||||
requestIds,
|
||||
onGoTo,
|
||||
onRename,
|
||||
}: {
|
||||
requestIds: string[];
|
||||
onGoTo(requestId: string): void;
|
||||
onRename(currentId: string, nextId: string): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.requests");
|
||||
const [selected, setSelected] = useState(requestIds[0] ?? "");
|
||||
const [nextId, setNextId] = useState(requestIds[0] ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestIds.includes(selected)) {
|
||||
setSelected(requestIds[0] ?? "");
|
||||
setNextId(requestIds[0] ?? "");
|
||||
}
|
||||
}, [requestIds, selected]);
|
||||
|
||||
if (requestIds.length === 0) return null;
|
||||
const rename = () => {
|
||||
try {
|
||||
onRename(selected, nextId);
|
||||
setSelected(nextId);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("rename"),
|
||||
message: error instanceof Error ? error.message : t("renameFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group align="end" grow wrap="wrap">
|
||||
<Select
|
||||
label={t("selected")}
|
||||
data={requestIds}
|
||||
value={selected}
|
||||
onChange={(value) => {
|
||||
setSelected(value ?? "");
|
||||
setNextId(value ?? "");
|
||||
}}
|
||||
searchable
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconTargetArrow size={16} />}
|
||||
onClick={() => onGoTo(selected)}
|
||||
>
|
||||
{t("goTo")}
|
||||
</Button>
|
||||
<TextInput label={t("newId")} value={nextId} onChange={(event) => setNextId(event.currentTarget.value)} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={16} />}
|
||||
disabled={!nextId || nextId === selected}
|
||||
onClick={rename}
|
||||
>
|
||||
{t("rename")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJsonArray, parseSources } from "./_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
|
||||
const secretFields: Record<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
|
||||
bearer: [{ kind: "apiKey" }],
|
||||
basic: [{ kind: "username" }, { kind: "password" }],
|
||||
apiKeyHeader: [{ kind: "apiKey" }],
|
||||
apiKeyQuery: [{ kind: "apiKey" }],
|
||||
};
|
||||
|
||||
export function CustomWidgetSourcesEditor({
|
||||
form,
|
||||
definitionId,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const utils = clientApi.useUtils();
|
||||
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
||||
const sources = parseSources(form.values.sources);
|
||||
const update = (index: number, changes: Partial<CustomWidgetSource>) => {
|
||||
const previousId = sources[index]?.id;
|
||||
const nextId = changes.id;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
sources.map((source, i) => (i === index ? { ...source, ...changes } : source)),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (previousId && nextId && previousId !== nextId) {
|
||||
const requests = parseJsonArray(form.values.requests).map((request) =>
|
||||
isRecord(request) && request.sourceId === previousId ? { ...request, sourceId: nextId } : request,
|
||||
);
|
||||
form.setFieldValue("requests", JSON.stringify(requests, null, 2));
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
form.values.secrets.map((secret) =>
|
||||
secret.sourceId === previousId ? { ...secret, sourceId: nextId } : secret,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
const setAuth = (index: number, type: string) => {
|
||||
const auth =
|
||||
type === "apiKeyHeader"
|
||||
? { type, headerName: "X-API-Key" }
|
||||
: type === "apiKeyQuery"
|
||||
? { type, parameterName: "api_key" }
|
||||
: { type };
|
||||
update(index, { auth: auth as CustomWidgetSource["auth"] });
|
||||
};
|
||||
const setSecret = (sourceId: string, kind: string, value: string) => {
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
const existing = form.values.secrets.find((secret) => secret.sourceId === sourceId && secret.kind === kind);
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
|
||||
};
|
||||
const addSource = () => {
|
||||
const id = `source-${sources.length + 1}`;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
[
|
||||
...sources,
|
||||
{
|
||||
id,
|
||||
name: t("newName", { count: sources.length + 1 }),
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "private",
|
||||
auth: { type: "none" },
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
};
|
||||
const removeSource = (index: number) => {
|
||||
const removedId = sources[index]?.id;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
sources.filter((_, i) => i !== index),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (!removedId) return;
|
||||
form.setFieldValue(
|
||||
"requests",
|
||||
JSON.stringify(
|
||||
parseJsonArray(form.values.requests).filter((request) => !isRecord(request) || request.sourceId !== removedId),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
|
||||
);
|
||||
};
|
||||
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
|
||||
if (!definitionId) return;
|
||||
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value: "", hasValue: false }]);
|
||||
await utils.customWidget.get.invalidate({ id: definitionId });
|
||||
showSuccessNotification({ title: t("credentialRemoved"), message: t("credentialRemovedDescription") });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{sources.map((source, index) => (
|
||||
<Fieldset key={source.id} legend={index === 0 ? t("primary") : source.name}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<TextInput
|
||||
label={t("id")}
|
||||
value={source.id}
|
||||
disabled={Boolean(definitionId)}
|
||||
onChange={(event) => update(index, { id: event.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("name")}
|
||||
value={source.name}
|
||||
onChange={(event) => update(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={source.baseUrl}
|
||||
onChange={(event) => update(index, { baseUrl: event.currentTarget.value })}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={source.networkScope}
|
||||
onChange={(value) =>
|
||||
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("authentication")}
|
||||
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
|
||||
value={source.auth.type}
|
||||
onChange={(value) => setAuth(index, value ?? "none")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
value={source.auth.headerName}
|
||||
onChange={(event) =>
|
||||
update(index, { auth: { type: "apiKeyHeader", headerName: event.currentTarget.value } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{source.auth.type === "apiKeyQuery" && (
|
||||
<TextInput
|
||||
label={t("queryParameter")}
|
||||
value={source.auth.parameterName}
|
||||
onChange={(event) =>
|
||||
update(index, { auth: { type: "apiKeyQuery", parameterName: event.currentTarget.value } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(secretFields[source.auth.type] ?? []).map((field) => {
|
||||
const secret = form.values.secrets.find(
|
||||
(entry) => entry.sourceId === source.id && entry.kind === field.kind,
|
||||
);
|
||||
const Input = field.kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Group key={field.kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${field.kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
|
||||
/>
|
||||
{definitionId && secret?.hasValue && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretMutation.isPending}
|
||||
onClick={() => void clearSecret(source.id, field.kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => removeSource(index)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
))}
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="advanced">
|
||||
<Accordion.Control>{t("advanced")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={addSource}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{form.errors.sources && (
|
||||
<Text c="red" size="xs">
|
||||
{form.errors.sources}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
export const listItemDefaults = {
|
||||
mapping: { label: "", jsonPath: "$", unit: "" },
|
||||
column: { header: "", jsonPath: "$" },
|
||||
statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" },
|
||||
progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" },
|
||||
statusItem: { label: "", jsonPath: "$", goodValues: "online,true" },
|
||||
countGridItem: { label: "", jsonPath: "$", unit: "" },
|
||||
} as const;
|
||||
|
||||
export function cloneLast<T extends Record<string, unknown>>(items: T[], fallback: T): T {
|
||||
return { ...(items.at(-1) ?? fallback) };
|
||||
}
|
||||
|
||||
export const MANTINE_COLORS = [
|
||||
"blue",
|
||||
"teal",
|
||||
"green",
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"violet",
|
||||
"pink",
|
||||
"cyan",
|
||||
"grape",
|
||||
"indigo",
|
||||
"lime",
|
||||
] as const;
|
||||
export const REQUEST_MANIFEST_STARTER = `[
|
||||
{
|
||||
"id": "details",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/api/details/{id}",
|
||||
"parameters": { "id": "string" },
|
||||
"auth": "inherit",
|
||||
"minimumBoardPermission": "view",
|
||||
"cacheTtlSeconds": 30
|
||||
}
|
||||
]`;
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { useZodForm } from "@homarr/form";
|
||||
import type { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
export type CustomWidgetFormInstance = ReturnType<typeof useZodForm<typeof customWidgetFormSchema>>;
|
||||
export type CustomWidgetTranslator = ReturnType<typeof useScopedI18n<"customWidget">>;
|
||||
|
||||
export interface DisplayFieldsProps {
|
||||
form: CustomWidgetFormInstance;
|
||||
t: CustomWidgetTranslator;
|
||||
previewJson: unknown;
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function BasicDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "singleValue") {
|
||||
return (
|
||||
<>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
description={t("field.jsonPathHint")}
|
||||
required
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps("jsonPath")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabel")}
|
||||
{...form.getInputProps("label")}
|
||||
/>
|
||||
<TextInput label={t("field.unit")} placeholder={t("placeholder.exampleUnit")} {...form.getInputProps("unit")} />
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg", "xl"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("valueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.labelPosition")}
|
||||
data={["above", "below"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`labelPositionOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("labelPosition")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "keyValue") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("kvLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{form.values.kvLayout === "grid" && (
|
||||
<NumberInput label={t("field.gridColumns")} min={1} max={3} {...form.getInputProps("kvColumns")} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.mappings")}
|
||||
</Text>
|
||||
{form.values.mappings.map((_m, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`mappings.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`mappings.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`mappings.${i}.unit`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("mappings", i)}
|
||||
disabled={form.values.mappings.length <= 1}
|
||||
aria-label={t("action.removeMapping")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => form.insertListItem("mappings", cloneLast(form.values.mappings, listItemDefaults.mapping))}
|
||||
>
|
||||
{t("action.addMapping")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "table") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.tablePath")}
|
||||
description={t("field.tablePathHint")}
|
||||
required
|
||||
placeholder={t("placeholder.tablePath")}
|
||||
{...form.getInputProps("tablePath")}
|
||||
/>
|
||||
<Group grow>
|
||||
<Switch label={t("field.striped")} {...form.getInputProps("striped", { type: "checkbox" })} />
|
||||
<Switch label={t("field.compact")} {...form.getInputProps("compact", { type: "checkbox" })} />
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.columns")}
|
||||
</Text>
|
||||
{form.values.columns.map((_col, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.header")}
|
||||
placeholder={t("placeholder.exampleHeader")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`columns.${i}.header`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.columnJsonPath")}
|
||||
{...form.getInputProps(`columns.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("columns", i)}
|
||||
disabled={form.values.columns.length <= 1}
|
||||
aria-label={t("action.removeColumn")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => form.insertListItem("columns", cloneLast(form.values.columns, listItemDefaults.column))}
|
||||
>
|
||||
{t("action.addColumn")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Alert, SegmentedControl, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeRequestManifest,
|
||||
CUSTOM_JSX_TEMPLATE_LIMIT,
|
||||
parseRequestManifest,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { REQUEST_MANIFEST_STARTER } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function CustomJsxDisplayFields({ form, t }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "customJsx") {
|
||||
const parsedRequests = parseRequestManifest(form.values.requestManifest);
|
||||
const requestIds = parsedRequests
|
||||
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
const templateDiagnostics = analyzeJsxTemplate(form.values.template, {
|
||||
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
|
||||
requestIds,
|
||||
});
|
||||
const manifestDiagnostics =
|
||||
form.values.jsxApiVersion === "2" ? analyzeRequestManifest(form.values.requestManifest) : [];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t("field.jsxApiVersion.label")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={form.values.jsxApiVersion}
|
||||
onChange={(value) => form.setFieldValue("jsxApiVersion", value)}
|
||||
data={[
|
||||
{ value: "2", label: t("field.jsxApiVersion.v2") },
|
||||
{ value: "1", label: t("field.jsxApiVersion.v1") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.values.jsxApiVersion === "1" ? (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{t("field.jsxApiVersion.legacyWarning")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("field.networkScope.label")}
|
||||
description={t(`field.networkScope.description.${form.values.networkScope}` as never)}
|
||||
value={form.values.networkScope}
|
||||
onChange={(value) => form.setFieldValue("networkScope", value ?? "public")}
|
||||
data={(["public", "private", "loopback"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`field.networkScope.option.${value}` as never),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<CodeEditor
|
||||
id="custom-widget-request-manifest"
|
||||
language="json"
|
||||
label={t("field.requestManifest.label")}
|
||||
description={t("field.requestManifest.description")}
|
||||
value={form.values.requestManifest}
|
||||
onChange={(value) => form.setFieldValue("requestManifest", value)}
|
||||
placeholder={REQUEST_MANIFEST_STARTER}
|
||||
starter={REQUEST_MANIFEST_STARTER}
|
||||
diagnostics={manifestDiagnostics}
|
||||
error={form.errors.requestManifest ? t("field.requestManifest.invalid") : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<CodeEditor
|
||||
id="custom-widget-jsx-template"
|
||||
language="jsx"
|
||||
label={t("field.template.label")}
|
||||
description={t("field.template.description")}
|
||||
value={form.values.template}
|
||||
onChange={(value) => form.setFieldValue("template", value)}
|
||||
placeholder={CUSTOM_JSX_STARTER}
|
||||
starter={CUSTOM_JSX_STARTER}
|
||||
diagnostics={templateDiagnostics}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={CUSTOM_JSX_TEMPLATE_LIMIT}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { ActionIcon, Box, Button, Group, Select, Stack, Switch, Text, TextInput, NumberInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function MetricDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "statGrid") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<NumberInput label={t("field.gridColumns")} min={1} max={4} {...form.getInputProps("statGridColumns")} />
|
||||
<Select
|
||||
label={t("field.cardStyle")}
|
||||
data={["filled", "outline", "subtle"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`cardStyleOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("cardStyle")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.statGridItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`statGridItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.unit`)}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.color")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
style={{ width: 100 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.color`)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("statGridItems", i)}
|
||||
disabled={form.values.statGridItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("statGridItems", cloneLast(form.values.statGridItems, listItemDefaults.statGridItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "progressBars") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Switch label={t("field.showPercentage")} {...form.getInputProps("showPercentage", { type: "checkbox" })} />
|
||||
<Select
|
||||
label={t("field.barSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("barSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.bars")}
|
||||
</Text>
|
||||
{form.values.progressBars.map((_bar, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`progressBars.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.valuePath")}
|
||||
placeholder={t("placeholder.valuePath")}
|
||||
{...form.getInputProps(`progressBars.${i}.valuePath`)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.maxPath")}
|
||||
placeholder={t("placeholder.maxPath")}
|
||||
{...form.getInputProps(`progressBars.${i}.maxPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 60 }}
|
||||
{...form.getInputProps(`progressBars.${i}.unit`)}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.color")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
style={{ width: 100 }}
|
||||
{...form.getInputProps(`progressBars.${i}.color`)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("progressBars", i)}
|
||||
disabled={form.values.progressBars.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("progressBars", cloneLast(form.values.progressBars, listItemDefaults.progressBar))
|
||||
}
|
||||
>
|
||||
{t("action.addBar")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function StatusDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "statusIndicator") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("statusLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.dotSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("dotSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.statusItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statusItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`statusItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.goodValues")}
|
||||
placeholder={t("placeholder.goodValues")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statusItems.${i}.goodValues`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("statusItems", i)}
|
||||
disabled={form.values.statusItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("statusItems", cloneLast(form.values.statusItems, listItemDefaults.statusItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "countGrid") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<NumberInput label={t("field.gridColumns")} min={2} max={4} {...form.getInputProps("countGridColumns")} />
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("countValueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.countGridItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`countGridItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`countGridItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`countGridItems.${i}.unit`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("countGridItems", i)}
|
||||
disabled={form.values.countGridItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("countGridItems", cloneLast(form.values.countGridItems, listItemDefaults.countGridItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "raw") {
|
||||
return (
|
||||
<>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
description={t("field.jsonPathHint")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps("rawJsonPath")}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("field.maxHeight")}
|
||||
min={50}
|
||||
max={1000}
|
||||
step={50}
|
||||
{...form.getInputProps("rawMaxHeight")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "actionButton") {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.buttonLabel")} required {...form.getInputProps("buttonLabel")} />
|
||||
<Select
|
||||
label={t("field.buttonColor")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
{...form.getInputProps("buttonColor")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.confirmText")}
|
||||
description={t("field.confirmTextHint")}
|
||||
{...form.getInputProps("confirmText")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.successMessage")}
|
||||
description={t("field.successMessageHint")}
|
||||
{...form.getInputProps("successMessage")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { SimpleGrid, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
Icon123,
|
||||
IconActivityHeartbeat,
|
||||
IconBraces,
|
||||
IconCode,
|
||||
IconHash,
|
||||
IconLayoutGrid,
|
||||
IconListDetails,
|
||||
IconPointer,
|
||||
IconProgress,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
import type { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetDisplayDescriptors } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { BasicDisplayFields } from "./_display-fields-basic";
|
||||
import { CustomJsxDisplayFields } from "./_display-fields-jsx";
|
||||
import { MetricDisplayFields } from "./_display-fields-metric";
|
||||
import { StatusDisplayFields } from "./_display-fields-status";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export const DISPLAY_TYPE_ICONS = {
|
||||
number: Icon123,
|
||||
keyValue: IconListDetails,
|
||||
table: IconTable,
|
||||
grid: IconLayoutGrid,
|
||||
progress: IconProgress,
|
||||
status: IconActivityHeartbeat,
|
||||
count: IconHash,
|
||||
json: IconBraces,
|
||||
action: IconPointer,
|
||||
code: IconCode,
|
||||
} as const;
|
||||
|
||||
export function DisplayTypePicker({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: CustomWidgetDisplayType) => void;
|
||||
t: ReturnType<typeof useScopedI18n<"customWidget">>;
|
||||
}) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
|
||||
{customWidgetDisplayDescriptors.map((descriptor) => {
|
||||
const displayType = descriptor.type;
|
||||
const Icon = DISPLAY_TYPE_ICONS[descriptor.icon];
|
||||
const selected = value === displayType;
|
||||
return (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
key={displayType}
|
||||
className={classes.displayTypeOption}
|
||||
data-selected={selected || undefined}
|
||||
onClick={() => onChange(displayType)}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<ThemeIcon variant={selected ? "filled" : "light"} size={42} radius="md">
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<div className={classes.displayTypeCopy}>
|
||||
<Text fw={600} size="sm">
|
||||
{t(`displayType.${displayType}` as never)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{t(`displayTypeDescription.${displayType}` as never)}
|
||||
</Text>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function DisplayTypeFields(props: DisplayFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<BasicDisplayFields {...props} />
|
||||
<MetricDisplayFields {...props} />
|
||||
<StatusDisplayFields {...props} />
|
||||
<CustomJsxDisplayFields {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Button, Center, Code, Paper, ScrollArea, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { createDisplayComponents } from "@homarr/widgets/custom-api/component";
|
||||
|
||||
interface PreviewWidgetPanelProps {
|
||||
data: Record<string, unknown> | null;
|
||||
displayType: string;
|
||||
success: boolean;
|
||||
responseInfo: { status: number; statusText: string } | null;
|
||||
previewSize: string;
|
||||
onPreviewSizeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
|
||||
|
||||
export function PreviewWidgetPanel({
|
||||
data,
|
||||
displayType,
|
||||
success,
|
||||
responseInfo,
|
||||
previewSize,
|
||||
onPreviewSizeChange,
|
||||
}: PreviewWidgetPanelProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const height = HEIGHTS[previewSize] ?? HEIGHTS.standard;
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={previewSize}
|
||||
onChange={onPreviewSizeChange}
|
||||
data={(["compact", "standard", "wide"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`preview.size.${value}` as never),
|
||||
}))}
|
||||
/>
|
||||
{data && (displayType === "actionButton" || success) ? (
|
||||
<>
|
||||
{success && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={responseInfo ? "green" : "gray"}
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{responseInfo ? `${responseInfo.status} ${responseInfo.statusText}` : t("preview.response.localSample")}
|
||||
</Badge>
|
||||
)}
|
||||
<Paper withBorder p="xs" h={height} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={data} />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Center h={height}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const widgetT = useScopedI18n("widget.customApi");
|
||||
const displayComponents = useMemo(() => createDisplayComponents(widgetT("openJson")), [widgetT]);
|
||||
const dataType = data.type as string | undefined;
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm" h="100%">
|
||||
<Button type="button" size="sm" color={(data.buttonColor as string) ?? "blue"} disabled>
|
||||
{(data.buttonLabel as string) ?? t("preview.execute")}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) return <Component data={data} />;
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { buildDisplayFormValues, getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
|
||||
import { CUSTOM_WIDGET_AUTH_SECRET_FIELDS } from "@homarr/custom-widgets/workbench";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
|
||||
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
|
||||
|
||||
interface UseCustomWidgetClipboardOptions {
|
||||
enabled: boolean;
|
||||
form: CustomWidgetFormInstance;
|
||||
t: CustomWidgetTranslator;
|
||||
onReplace: () => void;
|
||||
}
|
||||
|
||||
export function useCustomWidgetClipboard({ enabled, form, t, onReplace }: UseCustomWidgetClipboardOptions) {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const queueReplacement = useCallback(
|
||||
(widget: Record<string, unknown>) => {
|
||||
const review = getImportReview(widget);
|
||||
if (!review) return;
|
||||
openConfirmModal({
|
||||
title: t("importReview.replaceTitle"),
|
||||
children: (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("importReview.replaceDescription", { name: review.name, origin: review.origin })}</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge key={method} color={{ DELETE: "red", GET: "blue" }[method] ?? "orange"}>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
labels: { confirm: t("importReview.replaceConfirm"), cancel: t("importReview.cancel") },
|
||||
onConfirm: () => {
|
||||
const displayType = String(widget.displayType);
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
const authType = typeof widget.authType === "string" ? widget.authType : "none";
|
||||
const secrets = (CUSTOM_WIDGET_AUTH_SECRET_FIELDS[authType] ?? []).map((field) => {
|
||||
const existing = form.values.secrets.find((secret) => secret.kind === field.kind);
|
||||
return { kind: field.kind, value: existing?.value ?? "", hasValue: existing?.hasValue ?? false };
|
||||
});
|
||||
form.setValues({
|
||||
...form.values,
|
||||
name: String(widget.name),
|
||||
description: typeof widget.description === "string" ? widget.description : "",
|
||||
iconUrl: typeof widget.iconUrl === "string" ? widget.iconUrl : "",
|
||||
url: String(widget.url),
|
||||
authType,
|
||||
headerName: typeof widget.headerName === "string" ? widget.headerName : "",
|
||||
method: typeof widget.method === "string" ? widget.method : "GET",
|
||||
requestBody: typeof widget.requestBody === "string" ? widget.requestBody : "",
|
||||
...buildDisplayFormValues(displayType, displayConfig),
|
||||
secrets,
|
||||
});
|
||||
onReplace();
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.pastedForReplacement") });
|
||||
},
|
||||
});
|
||||
},
|
||||
[form, onReplace, openConfirmModal, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
|
||||
if (!widget) return;
|
||||
event.preventDefault();
|
||||
queueReplacement(widget);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [enabled, queueReplacement]);
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import {
|
||||
buildCustomWidgetFixPrompt,
|
||||
customWidgetDefinitionSchema,
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { applyDefinition, buildDefinition, loadPreviewQueries } from "./_custom-widget-form-utils";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
|
||||
interface FormActionsInput {
|
||||
mode: "create" | "edit";
|
||||
definitionId?: string;
|
||||
form: UseFormReturnType<CustomWidgetFormValues>;
|
||||
candidate: ReturnType<typeof buildDefinition>;
|
||||
templateDiagnostics: EditorDiagnostic[];
|
||||
requestDiagnostics: EditorDiagnostic[];
|
||||
preview: PreviewState;
|
||||
setPreview: Dispatch<SetStateAction<PreviewState>>;
|
||||
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
setStateSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
request: string;
|
||||
documentationUrl: string;
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const utils = clientApi.useUtils();
|
||||
const createMutation = clientApi.customWidget.create.useMutation();
|
||||
const updateMutation = clientApi.customWidget.update.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
|
||||
|
||||
const save = input.form.onSubmit(async (values) => {
|
||||
const definition = buildDefinition(values);
|
||||
if (!definition.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.save"),
|
||||
message: definition.error.issues[0]?.message ?? w("invalidWidget"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const changedSecrets = values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
sourceId,
|
||||
kind: kind as "apiKey" | "username" | "password",
|
||||
value,
|
||||
}));
|
||||
try {
|
||||
if (input.mode === "create") {
|
||||
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
|
||||
await utils.customWidget.list.invalidate();
|
||||
showSuccessNotification({
|
||||
title: t("action.create"),
|
||||
message: t("notification.created", { name: values.name }),
|
||||
});
|
||||
router.push(`/manage/custom-widgets/edit/${result.id}`);
|
||||
} else if (input.definitionId) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: input.definitionId,
|
||||
...definition.data,
|
||||
secrets: changedSecrets.length ? changedSecrets : undefined,
|
||||
});
|
||||
await Promise.all([
|
||||
utils.customWidget.list.invalidate(),
|
||||
utils.customWidget.get.invalidate({ id: input.definitionId }),
|
||||
utils.widget.customApi.getData.invalidate(),
|
||||
]);
|
||||
input.form.setInitialValues(values);
|
||||
input.form.resetDirty();
|
||||
showSuccessNotification({
|
||||
title: t("action.save"),
|
||||
message: t("notification.updated", { name: values.name }),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
|
||||
}
|
||||
});
|
||||
|
||||
const runPreview = async () => {
|
||||
if (!input.candidate.success) {
|
||||
showErrorNotification({
|
||||
title: w("section.preview"),
|
||||
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
input.setMobilePane("preview");
|
||||
try {
|
||||
const created = await previewMutation.mutateAsync({
|
||||
definition: input.candidate.data,
|
||||
definitionId: input.definitionId,
|
||||
secrets: input.form.values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
sourceId,
|
||||
kind: kind as "apiKey" | "username" | "password",
|
||||
value,
|
||||
})),
|
||||
});
|
||||
input.setOptionsSnapshot(input.candidate.data.defaultOptions);
|
||||
input.setStateSnapshot(input.candidate.data.defaultState ?? {});
|
||||
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
|
||||
input.setPreview({ ...snapshot, session: created.previewSession });
|
||||
} catch {
|
||||
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
|
||||
}
|
||||
};
|
||||
|
||||
const pasteAiResponse = async () => {
|
||||
try {
|
||||
const result = parseCustomWidgetClipboardDetailed(await navigator.clipboard.readText());
|
||||
if (!result.success) throw new Error(result.error);
|
||||
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
|
||||
input.setPreview({ data: {}, status: {}, session: null });
|
||||
showSuccessNotification({ title: w("ai.response"), message: w("ai.loaded") });
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: w("ai.response"),
|
||||
message:
|
||||
error instanceof Error ? w("ai.invalidResponseDetail", { message: error.message }) : w("ai.invalidResponse"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyDiagnostics = async () => {
|
||||
try {
|
||||
const diagnostics = [...input.templateDiagnostics, ...input.requestDiagnostics]
|
||||
.map((entry) => `${entry.severity.toUpperCase()} ${entry.code}: ${entry.value ?? ""}`)
|
||||
.join("\n");
|
||||
const journal = input.preview.session
|
||||
? await fetchApi.customWidget.previewJournal.query({ sessionId: input.preview.session.id }).catch(() => [])
|
||||
: [];
|
||||
await navigator.clipboard.writeText(
|
||||
buildCustomWidgetFixPrompt({
|
||||
currentConfig: input.candidate.success ? input.candidate.data : input.form.values,
|
||||
request: input.request,
|
||||
documentationUrl: input.documentationUrl,
|
||||
diagnostics,
|
||||
journal,
|
||||
}),
|
||||
);
|
||||
showSuccessNotification({ title: w("ai.diagnostics"), message: w("ai.diagnosticsCopied") });
|
||||
} catch {
|
||||
showErrorNotification({ title: w("ai.diagnostics"), message: t("notification.aiPromptCopyError") });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
save,
|
||||
runPreview,
|
||||
pasteAiResponse,
|
||||
copyDiagnostics,
|
||||
pending: createMutation.isPending || updateMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
DisplayConfig,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { buildDisplayConfigFromFormValues } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
|
||||
import type { PreviewFetchResult, PreviewInput } from "./_custom-widget-preview";
|
||||
|
||||
interface UseCustomWidgetPreviewOptions {
|
||||
form: CustomWidgetFormInstance;
|
||||
definitionId?: string;
|
||||
refreshSignal: number;
|
||||
onOpenPreview: () => void;
|
||||
t: CustomWidgetTranslator;
|
||||
}
|
||||
|
||||
export function useCustomWidgetPreview({
|
||||
form,
|
||||
definitionId,
|
||||
refreshSignal,
|
||||
onOpenPreview,
|
||||
t,
|
||||
}: UseCustomWidgetPreviewOptions) {
|
||||
const previewMutation = clientApi.customWidget.preview.useMutation();
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const [json, setJson] = useState<unknown>(null);
|
||||
const [fetchResult, setFetchResult] = useState<PreviewFetchResult | null>(null);
|
||||
const [lastFingerprint, setLastFingerprint] = useState<string | null>(null);
|
||||
const hasTestedRef = useRef(false);
|
||||
|
||||
const getInput = useCallback((): PreviewInput => {
|
||||
const values = form.values;
|
||||
return {
|
||||
url: values.url,
|
||||
method: values.method as CustomWidgetMethod,
|
||||
authType: values.authType as CustomWidgetAuthType,
|
||||
headerName: values.headerName || undefined,
|
||||
requestBody: values.requestBody || undefined,
|
||||
displayType: values.displayType as CustomWidgetDisplayType,
|
||||
displayConfig: buildDisplayConfigFromFormValues(values) as DisplayConfig,
|
||||
secrets: values.secrets
|
||||
.filter((secret) => secret.value)
|
||||
.map((secret) => ({ kind: secret.kind as CustomWidgetSecretKind, value: secret.value })),
|
||||
definitionId,
|
||||
};
|
||||
}, [definitionId, form.values]);
|
||||
|
||||
const test = useCallback(async () => {
|
||||
const input = getInput();
|
||||
if (!input.url || input.method !== "GET") return;
|
||||
onOpenPreview();
|
||||
setLastFingerprint(JSON.stringify(input));
|
||||
try {
|
||||
const result = await previewMutation.mutateAsync(input);
|
||||
hasTestedRef.current = true;
|
||||
setFetchResult({
|
||||
success: result.success,
|
||||
error: result.success ? undefined : result.error,
|
||||
responseInfo: result.responseInfo,
|
||||
rawResponse: result.rawResponse,
|
||||
previewSession: result.previewSession,
|
||||
});
|
||||
if (!result.success || !result.rawResponse) return setJson(null);
|
||||
try {
|
||||
setJson(JSON.parse(result.rawResponse));
|
||||
} catch {
|
||||
setJson(null);
|
||||
}
|
||||
} catch {
|
||||
setFetchResult({ success: false, error: t("notification.previewError"), responseInfo: null, rawResponse: null });
|
||||
setJson(null);
|
||||
}
|
||||
}, [getInput, onOpenPreview, previewMutation, t]);
|
||||
|
||||
const setLiveActions = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
const sessionId = fetchResult?.previewSession?.id;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const previewSession = await liveActionsMutation.mutateAsync({ sessionId, enabled });
|
||||
setFetchResult((current) => (current ? { ...current, previewSession } : current));
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("preview.capabilities.liveActions"),
|
||||
message: t("notification.previewActionsError"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchResult?.previewSession?.id, liveActionsMutation, t],
|
||||
);
|
||||
|
||||
const testRef = useRef(test);
|
||||
testRef.current = test;
|
||||
const getInputRef = useRef(getInput);
|
||||
getInputRef.current = getInput;
|
||||
const pendingRefreshRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (refreshSignal <= 0 || !hasTestedRef.current) return;
|
||||
if (previewMutation.isPending) return void (pendingRefreshRef.current = true);
|
||||
if (getInputRef.current().method === "GET") void testRef.current();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshSignal is the explicit event source
|
||||
}, [refreshSignal]);
|
||||
useEffect(() => {
|
||||
if (previewMutation.isPending || !pendingRefreshRef.current) return;
|
||||
pendingRefreshRef.current = false;
|
||||
if (getInputRef.current().method === "GET" && hasTestedRef.current) void testRef.current();
|
||||
}, [previewMutation.isPending]);
|
||||
|
||||
const input = getInput();
|
||||
const isStale = lastFingerprint !== null && lastFingerprint !== JSON.stringify(input);
|
||||
const insertDataPath = useCallback(
|
||||
(path: string) => {
|
||||
if (form.values.displayType !== "customJsx") return;
|
||||
const template = form.values.template.trimEnd();
|
||||
const snippet = `<Text>{${path}}</Text>`;
|
||||
form.setFieldValue("template", template ? `${template}\n${snippet}` : snippet);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
const setSampleData = useCallback((value: unknown) => {
|
||||
setJson(value);
|
||||
setFetchResult({ success: true, responseInfo: null, rawResponse: JSON.stringify(value, null, 2) });
|
||||
setLastFingerprint(null);
|
||||
}, []);
|
||||
const reset = useCallback(() => {
|
||||
setJson(null);
|
||||
setFetchResult(null);
|
||||
setLastFingerprint(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
input,
|
||||
getInput,
|
||||
json,
|
||||
fetchResult,
|
||||
isStale,
|
||||
test,
|
||||
setLiveActions,
|
||||
insertDataPath,
|
||||
setSampleData,
|
||||
reset,
|
||||
isTesting: previewMutation.isPending,
|
||||
testError: previewMutation.error,
|
||||
isUpdatingLiveActions: liveActionsMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -3,48 +3,24 @@ import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { buildDisplayFormValues } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { CustomWidgetBetaBanner } from "../../_beta-banner";
|
||||
import { CustomWidgetForm } from "../../_custom-widget-form";
|
||||
import { FormErrorBoundary } from "../../_form-error-boundary";
|
||||
|
||||
const authTypeExpectedSecrets: Record<string, string[]> = {
|
||||
bearer: ["apiKey"],
|
||||
basic: ["username", "password"],
|
||||
apiKeyHeader: ["apiKey"],
|
||||
apiKeyQuery: ["apiKey"],
|
||||
};
|
||||
|
||||
function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string; hasValue?: boolean }>) {
|
||||
const expected = authTypeExpectedSecrets[authType] ?? [];
|
||||
const existingKinds = new Set(dbSecrets.map((s) => s.kind));
|
||||
const result = dbSecrets.map((s) => ({ kind: s.kind, value: "", hasValue: s.hasValue ?? false }));
|
||||
for (const kind of expected) {
|
||||
if (!existingKinds.has(kind)) {
|
||||
result.push({ kind, value: "", hasValue: false });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface EditCustomWidgetPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditCustomWidgetPage(props: EditCustomWidgetPageProps) {
|
||||
const session = await auth();
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
const params = await props.params;
|
||||
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
const displayConfig = definition.displayConfig as Record<string, unknown>;
|
||||
const displayValues = buildDisplayFormValues(definition.displayType, displayConfig);
|
||||
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -52,7 +28,6 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
<Container fluid>
|
||||
<Stack>
|
||||
<Title>{definition.name}</Title>
|
||||
<CustomWidgetBetaBanner />
|
||||
<FormErrorBoundary>
|
||||
<CustomWidgetForm
|
||||
mode="edit"
|
||||
@@ -61,13 +36,19 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
name: definition.name,
|
||||
description: definition.description ?? "",
|
||||
iconUrl: definition.iconUrl ?? "",
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName ?? "",
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody ?? "",
|
||||
...displayValues,
|
||||
secrets: buildInitialSecrets(definition.authType, definition.secrets),
|
||||
sources: JSON.stringify(definition.sources, null, 2),
|
||||
requests: JSON.stringify(definition.requests, null, 2),
|
||||
optionsSchema: JSON.stringify(definition.optionsSchema, null, 2),
|
||||
defaultOptions: JSON.stringify(definition.defaultOptions, null, 2),
|
||||
stateSchema: JSON.stringify(definition.stateSchema ?? {}, null, 2),
|
||||
defaultState: JSON.stringify(definition.defaultState ?? {}, null, 2),
|
||||
template: definition.template,
|
||||
secrets: definition.secrets.map((secret) => ({
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
value: "",
|
||||
hasValue: true,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</FormErrorBoundary>
|
||||
|
||||
@@ -5,13 +5,12 @@ import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CustomWidgetBetaBanner } from "../_beta-banner";
|
||||
import { CustomWidgetForm } from "../_custom-widget-form";
|
||||
import { FormErrorBoundary } from "../_form-error-boundary";
|
||||
|
||||
export default async function NewCustomWidgetPage() {
|
||||
const session = await auth();
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
@@ -28,7 +27,6 @@ export default async function NewCustomWidgetPage() {
|
||||
{t("page.create.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
<CustomWidgetBetaBanner />
|
||||
<FormErrorBoundary>
|
||||
<CustomWidgetForm mode="create" />
|
||||
</FormErrorBoundary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Group } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { IconDownload, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -9,18 +9,18 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
|
||||
export default async function CustomWidgetsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
|
||||
redirect(session ? "/" : "/auth/login");
|
||||
}
|
||||
|
||||
const definitions = await api.customWidget.all();
|
||||
const definitions = await api.customWidget.list();
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
@@ -28,6 +28,15 @@ export default async function CustomWidgetsPage() {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<MobileAffixButton
|
||||
component="a"
|
||||
href="https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
variant="default"
|
||||
>
|
||||
{t("action.downloadSkill")}
|
||||
</MobileAffixButton>
|
||||
<WorkshopInstallButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
@@ -35,7 +44,6 @@ export default async function CustomWidgetsPage() {
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetList definitions={definitions} />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Badge, Box, Container, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("custom-widget-manage")) redirect("/");
|
||||
const { id } = await params;
|
||||
const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null);
|
||||
if (!preview) notFound();
|
||||
|
||||
const loadRequests = preview.requests.filter((request) => request.kind === "query" && request.trigger === "load");
|
||||
const results = await Promise.all(
|
||||
loadRequests.map(async (request) => {
|
||||
const requestParams = Object.fromEntries(
|
||||
Object.entries(request.parameters).flatMap(([name, type]) => {
|
||||
const value = preview.defaultOptions[name];
|
||||
return typeof value === type ? [[name, value as string | number | boolean]] : [];
|
||||
}),
|
||||
);
|
||||
const result = await api.customWidget
|
||||
.previewQuery({ sessionId: id, requestId: request.id, params: requestParams })
|
||||
.catch((cause: unknown) => ({
|
||||
ok: false,
|
||||
status: 0,
|
||||
statusText: "Error",
|
||||
data: null,
|
||||
error: cause instanceof Error ? cause.message : "Request failed",
|
||||
}));
|
||||
return [request.id, result] as const;
|
||||
}),
|
||||
);
|
||||
const data = Object.fromEntries(results.map(([requestId, result]) => [requestId, result.data]));
|
||||
const status = Object.fromEntries(
|
||||
results.map(([requestId, result]) => [
|
||||
requestId,
|
||||
{ loading: false, ok: result.ok, status: result.status, error: result.error },
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="end">
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" fw={600}>
|
||||
CUSTOM WIDGET PREVIEW
|
||||
</Text>
|
||||
<Title>{preview.name}</Title>
|
||||
</Box>
|
||||
<Badge variant="light">Expires {new Date(preview.expiresAt).toLocaleTimeString()}</Badge>
|
||||
</Group>
|
||||
<Paper withBorder radius="lg" p="md" mih={360} style={{ overflow: "auto" }}>
|
||||
<CustomJsxDisplay
|
||||
data={{
|
||||
template: preview.template,
|
||||
data,
|
||||
status,
|
||||
options: preview.defaultOptions,
|
||||
stateSchema: preview.stateSchema,
|
||||
defaultState: preview.defaultState,
|
||||
requestCapabilities: preview.requests,
|
||||
previewSessionId: preview.id,
|
||||
previewLiveActions: preview.liveActions,
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IconApi,
|
||||
IconBook2,
|
||||
IconBox,
|
||||
IconBuildingStore,
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
@@ -78,7 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconApi,
|
||||
href: "/manage/custom-widgets",
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("custom-widget-manage"),
|
||||
},
|
||||
{
|
||||
icon: IconBuildingStore,
|
||||
href: "/manage/workshop",
|
||||
label: t("items.workshop"),
|
||||
hidden: !session?.user.permissions.includes("custom-widget-manage"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
|
||||
|
||||
export default async function WorkshopPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("custom-widget-manage")) redirect(session ? "/" : "/auth/login");
|
||||
const t = await getScopedI18n("workshop");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")} size="xl">
|
||||
<WorkshopInstaller />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/core";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({
|
||||
version: 2,
|
||||
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
resources: [
|
||||
"homarr://custom-widgets/schema",
|
||||
"homarr://custom-widgets/components",
|
||||
"homarr://custom-widgets/skill",
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
claimCustomWidgetSecretRequest,
|
||||
completeCustomWidgetSecretRequest,
|
||||
getCustomWidgetSecretRequest,
|
||||
releaseCustomWidgetSecretRequest,
|
||||
setPreviewSessionSecrets,
|
||||
} from "@homarr/api/custom-widget-secrets";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
const request = await getCustomWidgetSecretRequest(token);
|
||||
if (!request) return NextResponse.json({ error: "This credential request is invalid or expired." }, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
widgetName: request.widgetName,
|
||||
sourceName: request.sourceName,
|
||||
kinds: request.kinds,
|
||||
expiresAt: request.expiresAt,
|
||||
status: request.status,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
const pending = await getCustomWidgetSecretRequest(token);
|
||||
if (!pending || pending.status !== "pending") {
|
||||
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
|
||||
}
|
||||
const body = (await request.json().catch(() => null)) as { secrets?: Record<string, unknown> } | null;
|
||||
const secrets = pending.kinds.flatMap((kind) => {
|
||||
const value = body?.secrets?.[kind];
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 8192
|
||||
? [{ sourceId: pending.sourceId, kind, value }]
|
||||
: [];
|
||||
});
|
||||
if (secrets.length !== pending.kinds.length) {
|
||||
return NextResponse.json({ error: "Enter every requested credential field." }, { status: 400 });
|
||||
}
|
||||
|
||||
const claimed = await claimCustomWidgetSecretRequest(token);
|
||||
if (!claimed) {
|
||||
return NextResponse.json({ error: "This credential request is already being completed." }, { status: 409 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (claimed.target.type === "preview") {
|
||||
await setPreviewSessionSecrets(claimed.target.id, claimed.userId, secrets);
|
||||
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
|
||||
} else {
|
||||
const definition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, claimed.target.id),
|
||||
});
|
||||
if (!definition) return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
|
||||
for (const secret of secrets) {
|
||||
await db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, claimed.target.id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await db.insert(customWidgetSecrets).values({
|
||||
definitionId: claimed.target.id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, claimed.target.id));
|
||||
}
|
||||
|
||||
await completeCustomWidgetSecretRequest(token);
|
||||
return NextResponse.json({ status: "completed" }, { headers: { "Cache-Control": "no-store" } });
|
||||
} finally {
|
||||
await releaseCustomWidgetSecretRequest(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/core";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetSkill());
|
||||
}
|
||||
@@ -3,13 +3,29 @@ import type { NextRequest } from "next/server";
|
||||
import { userAgent } from "next/server";
|
||||
import { createMcpHandler } from "mcp-handler";
|
||||
import type { McpTool } from "trpc-to-mcp";
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import {
|
||||
buildCustomWidgetMcpPrompt,
|
||||
customJsxExamples,
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetJsonSchema,
|
||||
getCustomWidgetSkill,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
@@ -168,6 +184,92 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
||||
prompts: [
|
||||
{
|
||||
name: "homarr-custom-widget-author",
|
||||
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
|
||||
arguments: [
|
||||
{ name: "request", description: "The widget the user wants.", required: false },
|
||||
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
||||
if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found");
|
||||
return {
|
||||
description: "Current Homarr Custom Widget authoring workflow.",
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: buildCustomWidgetMcpPrompt(
|
||||
request.params.arguments?.request,
|
||||
request.params.arguments?.documentationUrl,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
||||
resources: [
|
||||
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
|
||||
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
|
||||
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
|
||||
...customJsxExamples.map((example) => ({
|
||||
uri: `homarr://custom-widgets/examples/${example.id}`,
|
||||
name: `Custom Widget example: ${example.title}`,
|
||||
mimeType: "application/json",
|
||||
})),
|
||||
],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
|
||||
resourceTemplates: [
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/components/{name}",
|
||||
name: "Custom Widget component",
|
||||
description: "Metadata for one installed Custom Widget component.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/examples/{name}",
|
||||
name: "Custom Widget example",
|
||||
description: "One canonical Custom Widget example.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
|
||||
const { uri } = request.params;
|
||||
let text: string;
|
||||
let mimeType = "application/json";
|
||||
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
|
||||
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
|
||||
else if (uri === "homarr://custom-widgets/skill") {
|
||||
const skill = getCustomWidgetSkill();
|
||||
text = `${skill.skillMd}\n\nMatching archive: ${skill.downloadUrl}\nSHA-256: ${skill.sha256}\n`;
|
||||
mimeType = "text/markdown";
|
||||
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
|
||||
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
|
||||
const component = getCustomWidgetComponent(name);
|
||||
if (!component) throw new Error("Component resource not found");
|
||||
text = JSON.stringify(component);
|
||||
} else if (uri.startsWith("homarr://custom-widgets/examples/")) {
|
||||
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/examples/".length));
|
||||
const example = customJsxExamples.find((candidate) => candidate.id === name);
|
||||
if (!example) throw new Error("Example resource not found");
|
||||
text = JSON.stringify(example);
|
||||
} else throw new Error("Resource not found");
|
||||
return { contents: [{ uri, mimeType, text }] };
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: getTools(),
|
||||
}));
|
||||
@@ -219,7 +321,7 @@ const mcpHandler = createMcpHandler(
|
||||
});
|
||||
},
|
||||
{
|
||||
capabilities: { tools: {} },
|
||||
capabilities: { tools: {}, prompts: {}, resources: {} },
|
||||
instructions: SERVER_INSTRUCTIONS,
|
||||
serverInfo: {
|
||||
name: "homarr",
|
||||
|
||||
@@ -100,7 +100,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const { removeItem, updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
@@ -137,6 +137,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
removeItem={() => removeItem({ itemId: item.id })}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
@@ -22,7 +23,8 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
const board = useRequiredBoard();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: board.id });
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
@@ -61,17 +63,41 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
}, [items, search]);
|
||||
|
||||
const filteredCustomWidgets = useMemo(
|
||||
() =>
|
||||
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
() => (customWidgetDefs ?? []).filter((def) => def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const handleAddCustomWidget = (definitionId: string) => {
|
||||
const handleAddCustomWidget = (definition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
definitionId: definition.id,
|
||||
configuration: definition.defaultOptions,
|
||||
configurationVersion: definition.updatedAt.getTime(),
|
||||
};
|
||||
actions.closeModal();
|
||||
openEditModal(
|
||||
{
|
||||
kind: "customApi",
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
@@ -180,7 +206,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
|
||||
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBuildingStore,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconSearch,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
} from "@tabler/icons-react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
|
||||
import { WORKSHOP_API_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
|
||||
|
||||
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
|
||||
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = workshopExportFilename(submission.title);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function WorkshopBrowser({ onInstall }: { onInstall?(submission: WorkshopSubmissionDetail): Promise<void> }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [reportOpened, reportControls] = useDisclosure(false);
|
||||
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
|
||||
const [reportExplanation, setReportExplanation] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["workshop", "list", page, sort, debouncedSearch],
|
||||
queryFn: ({ signal }) => client.list({ page, perPage: 12, sort, search: debouncedSearch, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const detail = useQuery({
|
||||
queryKey: ["workshop", "detail", selectedId],
|
||||
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
|
||||
enabled: selectedId !== null,
|
||||
});
|
||||
const login = useMutation({ mutationFn: () => client.signInWithGitHub(), onSuccess: setUser });
|
||||
const install = useMutation({
|
||||
mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission),
|
||||
onSuccess: () => setSelectedId(null),
|
||||
});
|
||||
const vote = useMutation({
|
||||
mutationFn: ({ submission, value }: { submission: string; value: 1 | -1 }) => client.vote(submission, value),
|
||||
onSuccess: async () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workshop", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workshop", "detail"] }),
|
||||
]),
|
||||
});
|
||||
const report = useMutation({
|
||||
mutationFn: () => client.report(selectedId ?? "", reportCategory, reportExplanation),
|
||||
onSuccess: () => {
|
||||
reportControls.close();
|
||||
setReportExplanation("");
|
||||
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group align="flex-start">
|
||||
<Box
|
||||
p="sm"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
|
||||
>
|
||||
<IconBuildingStore size={24} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
<Text c="dimmed" size="sm" maw={640}>
|
||||
{t("description")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{user ? (
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{user.displayName}</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
client.signOut();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Button size="xs" loading={login.isPending} onClick={() => login.mutate()}>
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("securityNotice")}
|
||||
</Alert>
|
||||
{login.error && <Alert color="red">{login.error.message}</Alert>}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput
|
||||
label={t("search")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("sort")}
|
||||
value={sort}
|
||||
onChange={(value) => {
|
||||
setSort(value === "newest" ? "newest" : "top");
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ value: "top", label: t("sortTop") },
|
||||
{ value: "newest", label: t("sortNewest") },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{list.isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
</SimpleGrid>
|
||||
) : list.isError ? (
|
||||
<Alert color="red">{t("unavailable")}</Alert>
|
||||
) : list.data?.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{list.data.items.map((item) => (
|
||||
<Card key={item.id} withBorder radius="md" p="md">
|
||||
{item.screenshots[0] ? (
|
||||
<Card.Section>
|
||||
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
|
||||
</Card.Section>
|
||||
) : (
|
||||
<Card.Section>
|
||||
<Box
|
||||
h={170}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ display: "grid", placeItems: "center" }}
|
||||
>
|
||||
<Code>JSX</Code>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
)}
|
||||
<Stack gap="xs" mt="md">
|
||||
<Group justify="space-between">
|
||||
<Badge variant="light">v{item.revision}</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{item.description || t("noDescription")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("author", { name: item.authorName })}
|
||||
</Text>
|
||||
<Button variant="light" onClick={() => setSelectedId(item.id)}>
|
||||
{t("inspect")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
)}
|
||||
{(list.data?.totalPages ?? 0) > 1 && (
|
||||
<Pagination total={list.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={selectedId !== null}
|
||||
onClose={() => setSelectedId(null)}
|
||||
title={detail.data?.title ?? t("details")}
|
||||
size="xl"
|
||||
>
|
||||
{detail.isPending ? (
|
||||
<Skeleton h={400} />
|
||||
) : detail.isError || !detail.data ? (
|
||||
<Alert color="red">{t("loadError")}</Alert>
|
||||
) : (
|
||||
<Stack>
|
||||
<Text>{detail.data.description}</Text>
|
||||
<Group>
|
||||
<Badge>{t("revision", { revision: detail.data.revision })}</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
SHA-256 {detail.data.contentHash.slice(0, 12)}…
|
||||
</Text>
|
||||
</Group>
|
||||
{detail.data.screenshots.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{detail.data.screenshots.map((file, index) => (
|
||||
<Image
|
||||
key={file}
|
||||
src={client.fileUrl(detail.data.id, file, "960x640")}
|
||||
radius="md"
|
||||
alt={`${detail.data.title} screenshot ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<CapabilitySummary content={detail.data.content} />
|
||||
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
|
||||
{detail.data.content}
|
||||
</Code>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => downloadWorkshopSubmission(detail.data)}
|
||||
>
|
||||
{t("export")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconThumbUp size={16} />}
|
||||
disabled={!user}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
|
||||
>
|
||||
{detail.data.upvotes}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconThumbDown size={16} />}
|
||||
disabled={!user}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
|
||||
>
|
||||
{detail.data.downvotes}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconFlag size={16} />}
|
||||
disabled={!user}
|
||||
onClick={reportControls.open}
|
||||
>
|
||||
{t("report")}
|
||||
</Button>
|
||||
</Group>
|
||||
{onInstall && (
|
||||
<Button loading={install.isPending} onClick={() => install.mutate(detail.data)}>
|
||||
{t("install")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{!user && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("signInHint")}
|
||||
</Text>
|
||||
)}
|
||||
{vote.error && <Alert color="red">{t("voteError")}</Alert>}
|
||||
{install.error && <Alert color="red">{install.error.message}</Alert>}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal opened={reportOpened} onClose={reportControls.close} title={t("reportTitle")}>
|
||||
<Stack>
|
||||
<Select
|
||||
label={t("reportReason")}
|
||||
value={reportCategory}
|
||||
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
|
||||
data={["malicious", "spam", "copyright", "inappropriate", "other"]}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("reportExplanation")}
|
||||
minRows={4}
|
||||
value={reportExplanation}
|
||||
onChange={(event) => setReportExplanation(event.currentTarget.value)}
|
||||
/>
|
||||
{report.error && <Alert color="red">{report.error.message}</Alert>}
|
||||
<Button
|
||||
loading={report.isPending}
|
||||
disabled={reportExplanation.trim().length < 3}
|
||||
onClick={() => report.mutate()}
|
||||
>
|
||||
{t("reportSend")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilitySummary({ content }: { content: string }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
let candidate: unknown;
|
||||
try {
|
||||
candidate = JSON.parse(content) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = customWidgetImportSchema.safeParse(candidate);
|
||||
if (!parsed.success) return null;
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Card withBorder p="sm">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("sources")}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{parsed.data.sources.map((source) => (
|
||||
<Box key={source.id}>
|
||||
<Text size="sm" fw={500}>
|
||||
{source.name}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light">
|
||||
{new URL(source.baseUrl).origin}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{source.networkScope}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{source.auth.type}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card withBorder p="sm">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("requests")}
|
||||
</Text>
|
||||
{parsed.data.requests.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("noRequests")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{parsed.data.requests.map((request) => (
|
||||
<Group key={request.id} justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{request.id}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs">{request.kind}</Badge>
|
||||
<Badge size="xs" variant="light">
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{request.minimumBoardPermission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Modal } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopInstallButton() {
|
||||
const t = useScopedI18n("workshop");
|
||||
const [opened, controls] = useDisclosure(false);
|
||||
const mutation = clientApi.customWidget.import.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={controls.open}>
|
||||
{t("title")}
|
||||
</Button>
|
||||
<Modal opened={opened} onClose={controls.close} title={t("installDialog")} size="90%">
|
||||
<WorkshopBrowser
|
||||
onInstall={async (submission) => {
|
||||
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content));
|
||||
await mutation.mutateAsync(widget);
|
||||
await utils.customWidget.list.invalidate();
|
||||
showSuccessNotification({
|
||||
title: t("installed"),
|
||||
message: t("installedDescription", { name: widget.name }),
|
||||
});
|
||||
controls.close();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopSubmissionDetail } from "@homarr/workshop";
|
||||
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopInstaller() {
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation();
|
||||
|
||||
const install = async (submission: WorkshopSubmissionDetail) => {
|
||||
try {
|
||||
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content) as unknown);
|
||||
const result = await importMutation.mutateAsync(widget);
|
||||
await utils.customWidget.list.invalidate();
|
||||
showSuccessNotification({ title: t("installSuccess"), message: t("installSuccessDescription") });
|
||||
router.push(`/manage/custom-widgets/edit/${result.id}`);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("installError"),
|
||||
message: error instanceof Error ? error.message : t("installErrorDescription"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return <WorkshopBrowser onInstall={install} />;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WorkshopUser } from "@homarr/workshop";
|
||||
import { WORKSHOP_API_URL, WorkshopClient } from "@homarr/workshop";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
|
||||
|
||||
export function WorkshopPublishModal({
|
||||
opened,
|
||||
onClose,
|
||||
widget,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose(): void;
|
||||
widget: { id: string; name: string };
|
||||
}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
|
||||
const utils = clientApi.useUtils();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [published, setPublished] = useState(false);
|
||||
const initialChangelog = t("publish.initialChangelog");
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setTitle(widget.name);
|
||||
setDescription("");
|
||||
setChangelog(initialChangelog);
|
||||
setScreenshots([]);
|
||||
setError(null);
|
||||
setPublished(false);
|
||||
}, [initialChangelog, opened, widget.name]);
|
||||
|
||||
const publish = async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const definition = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
await client.create({ title, description, changelog, content: JSON.stringify(definition) }, screenshots);
|
||||
setPublished(true);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("publish.error"));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg">
|
||||
<Stack>
|
||||
{published ? (
|
||||
<Alert color="green">{t("publish.success")}</Alert>
|
||||
) : (
|
||||
<>
|
||||
{!user && (
|
||||
<Alert color="blue">
|
||||
<Group justify="space-between">
|
||||
<span>{t("publish.signInHint")}</span>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: Error) => setError(cause.message))
|
||||
}
|
||||
>
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t("publish.titleField")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.descriptionField")}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.changelog")}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
<FileInput
|
||||
label={t("publish.screenshots")}
|
||||
description={t("publish.screenshotsDescription")}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
value={screenshots}
|
||||
onChange={setScreenshots}
|
||||
/>
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Button loading={pending} disabled={!user || title.trim().length < 3} onClick={() => void publish()}>
|
||||
{t("publish.action")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pb_data/
|
||||
@@ -0,0 +1,51 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
ENV COREPACK_HOME=/corepack
|
||||
RUN mkdir -p /corepack && corepack enable pnpm && corepack install --global pnpm@11.11.0
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
|
||||
COPY patches ./patches
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set store-dir /pnpm/store && pnpm fetch --ignore-scripts
|
||||
COPY apps/docs ./apps/docs
|
||||
COPY packages/custom-widgets ./packages/custom-widgets
|
||||
COPY packages/definitions ./packages/definitions
|
||||
COPY packages/workshop ./packages/workshop
|
||||
COPY tooling/typescript ./tooling/typescript
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm install --recursive --offline --frozen-lockfile
|
||||
|
||||
FROM dependencies AS website
|
||||
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
|
||||
FROM alpine:3.22 AS pocketbase
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in amd64) PB_ARCH=amd64 ;; arm64) PB_ARCH=arm64 ;; arm) PB_ARCH=armv7 ;; *) exit 1 ;; esac && \
|
||||
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
|
||||
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" -o "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" -o /tmp/checksums.txt && \
|
||||
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22 AS development
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
|
||||
FROM alpine:3.22 AS production
|
||||
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY --from=website /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
|
||||
USER pocketbase
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.next
|
||||
.turbo
|
||||
**/node_modules
|
||||
**/pb_data
|
||||
apps/docs/build
|
||||
coverage
|
||||
@@ -0,0 +1,21 @@
|
||||
# Homarr Workshop
|
||||
|
||||
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
|
||||
|
||||
It intentionally has no approval queue, user bans, reusable widget connections, or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report; records in `workshop_admins` can inspect reports, dismiss them, and delete any submission.
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, configure GitHub OAuth on the `users` collection, and add Workshop administrators through the `workshop_admins` collection.
|
||||
|
||||
Run the disposable service integration test from the repository root:
|
||||
|
||||
```sh
|
||||
pnpm test:workshop
|
||||
```
|
||||
|
||||
Production data lives in `/pb_data`. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
|
||||
@@ -0,0 +1,21 @@
|
||||
name: homarr-workshop
|
||||
|
||||
services:
|
||||
workshop:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: development
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
exec pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations \
|
||||
--publicDir=/pb_public \
|
||||
--indexFallback=true
|
||||
@@ -0,0 +1,666 @@
|
||||
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
const NETWORK_SCOPES = ["public", "private", "loopback"];
|
||||
const PERMISSIONS = ["view", "modify", "full"];
|
||||
const PARAMETER_TYPES = ["string", "number", "boolean"];
|
||||
const TOP_LEVEL_FIELDS = [
|
||||
"$schema",
|
||||
"name",
|
||||
"description",
|
||||
"iconUrl",
|
||||
"sources",
|
||||
"requests",
|
||||
"optionsSchema",
|
||||
"defaultOptions",
|
||||
"stateSchema",
|
||||
"defaultState",
|
||||
"template",
|
||||
];
|
||||
const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"];
|
||||
const REQUEST_FIELDS = [
|
||||
"id",
|
||||
"sourceId",
|
||||
"kind",
|
||||
"method",
|
||||
"pathTemplate",
|
||||
"parameters",
|
||||
"queryTemplate",
|
||||
"bodyTemplate",
|
||||
"staticHeaders",
|
||||
"auth",
|
||||
"minimumBoardPermission",
|
||||
"trigger",
|
||||
"cacheTtlSeconds",
|
||||
"confirmation",
|
||||
"invalidates",
|
||||
];
|
||||
const OPTION_FIELDS = [
|
||||
"type",
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"enum",
|
||||
"const",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"format",
|
||||
"items",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"x-homarr",
|
||||
];
|
||||
const OPTION_TYPES = ["object", "array", "string", "number", "integer", "boolean", "null"];
|
||||
const OPTION_CONTROLS = [
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"switch",
|
||||
"select",
|
||||
"multi-select",
|
||||
"slider",
|
||||
"date",
|
||||
"time",
|
||||
"color",
|
||||
"icon",
|
||||
"url",
|
||||
"duration",
|
||||
"timeZone",
|
||||
"json",
|
||||
];
|
||||
const OPTION_FORMATS = ["date", "date-time", "time", "color", "uri", "icon", "duration", "time-zone"];
|
||||
const RESERVED_HEADERS = [
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
];
|
||||
const SENSITIVE_KEY =
|
||||
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/i;
|
||||
const CREDENTIAL_TEXT =
|
||||
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/i;
|
||||
|
||||
function bad(message) {
|
||||
throw new BadRequestError(message);
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validId(value) {
|
||||
return typeof value === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(value);
|
||||
}
|
||||
|
||||
function validBindingId(value) {
|
||||
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
|
||||
}
|
||||
|
||||
function validHttpUrl(value) {
|
||||
return typeof value === "string" && /^https?:\/\/[^\s]+$/i.test(value);
|
||||
}
|
||||
|
||||
function protectedHeader(name) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
RESERVED_HEADERS.includes(normalized) ||
|
||||
normalized.startsWith("proxy-") ||
|
||||
normalized.startsWith("sec-") ||
|
||||
normalized.startsWith("x-forwarded-")
|
||||
);
|
||||
}
|
||||
|
||||
function validHeaderName(name) {
|
||||
return (
|
||||
typeof name === "string" &&
|
||||
name.length <= 256 &&
|
||||
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) &&
|
||||
!protectedHeader(name)
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateSource(source) {
|
||||
if (
|
||||
!isObject(source) ||
|
||||
Object.keys(source).some((key) => !SOURCE_FIELDS.includes(key)) ||
|
||||
!validId(source.id) ||
|
||||
typeof source.name !== "string" ||
|
||||
!source.name.trim() ||
|
||||
source.name.length > 128 ||
|
||||
typeof source.baseUrl !== "string" ||
|
||||
!NETWORK_SCOPES.includes(source.networkScope) ||
|
||||
!isObject(source.auth) ||
|
||||
!AUTH_TYPES.includes(source.auth.type)
|
||||
)
|
||||
bad("Widget contains an invalid source");
|
||||
if (!/^https?:\/\/[^/?#\s]+(?:\/[^?#\s]*)?$/i.test(source.baseUrl)) bad("Widget source URL is invalid");
|
||||
const authority = source.baseUrl.replace(/^https?:\/\//i, "").split("/")[0];
|
||||
if (authority.includes("@")) bad("Widget source URLs must not contain credentials or query values");
|
||||
const authFields =
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? ["type", "headerName"]
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? ["type", "parameterName"]
|
||||
: ["type"];
|
||||
if (Object.keys(source.auth).some((key) => !authFields.includes(key))) bad("Widget source authentication is invalid");
|
||||
if (source.auth.type === "apiKeyHeader" && !validHeaderName(source.auth.headerName))
|
||||
bad("API key authentication requires a safe HTTP header name");
|
||||
if (
|
||||
source.auth.type === "apiKeyQuery" &&
|
||||
(typeof source.auth.parameterName !== "string" ||
|
||||
!source.auth.parameterName ||
|
||||
source.auth.parameterName.length > 256 ||
|
||||
hasControlCharacter(source.auth.parameterName))
|
||||
)
|
||||
bad("API key query authentication requires a safe parameterName");
|
||||
}
|
||||
|
||||
function validateRequest(request, sourceIds) {
|
||||
if (
|
||||
!isObject(request) ||
|
||||
Object.keys(request).some((key) => !REQUEST_FIELDS.includes(key)) ||
|
||||
!validId(request.id) ||
|
||||
!sourceIds.has(request.sourceId) ||
|
||||
!["query", "action"].includes(request.kind) ||
|
||||
!METHODS.includes(request.method) ||
|
||||
typeof request.pathTemplate !== "string" ||
|
||||
!request.pathTemplate.startsWith("/") ||
|
||||
request.pathTemplate.startsWith("//") ||
|
||||
request.pathTemplate.includes("\\") ||
|
||||
request.pathTemplate.includes("#") ||
|
||||
!isObject(request.parameters) ||
|
||||
!["inherit", "none"].includes(request.auth) ||
|
||||
!PERMISSIONS.includes(request.minimumBoardPermission)
|
||||
)
|
||||
bad("Widget contains an invalid request");
|
||||
if (
|
||||
Object.keys(request.parameters).length > 32 ||
|
||||
Object.entries(request.parameters).some(([name, type]) => !validBindingId(name) || !PARAMETER_TYPES.includes(type))
|
||||
)
|
||||
bad("Widget request parameters are invalid");
|
||||
const trigger = request.trigger || "manual";
|
||||
if (!["load", "manual"].includes(trigger) || (request.kind === "action" && trigger === "load"))
|
||||
bad("Actions cannot run automatically");
|
||||
if (request.kind === "query" && request.confirmation !== undefined) bad("Only actions can require confirmation");
|
||||
if (request.kind === "query" && request.invalidates !== undefined) bad("Only actions can invalidate queries");
|
||||
if (request.method === "DELETE" && request.minimumBoardPermission !== "full")
|
||||
bad("DELETE requests require full permission");
|
||||
if (
|
||||
request.queryTemplate !== undefined &&
|
||||
(!isObject(request.queryTemplate) || Object.keys(request.queryTemplate).some((key) => !key || key.length > 256))
|
||||
)
|
||||
bad("Widget query template is invalid");
|
||||
if (
|
||||
request.confirmation !== undefined &&
|
||||
(!isObject(request.confirmation) ||
|
||||
Object.keys(request.confirmation).some(
|
||||
(key) => !["title", "message", "confirmLabel", "destructive"].includes(key),
|
||||
) ||
|
||||
typeof request.confirmation.title !== "string" ||
|
||||
!request.confirmation.title ||
|
||||
request.confirmation.title.length > 128 ||
|
||||
typeof request.confirmation.message !== "string" ||
|
||||
!request.confirmation.message ||
|
||||
request.confirmation.message.length > 512 ||
|
||||
(request.confirmation.confirmLabel !== undefined &&
|
||||
(typeof request.confirmation.confirmLabel !== "string" ||
|
||||
!request.confirmation.confirmLabel ||
|
||||
request.confirmation.confirmLabel.length > 64)) ||
|
||||
(request.confirmation.destructive !== undefined && typeof request.confirmation.destructive !== "boolean"))
|
||||
)
|
||||
bad("Widget action confirmation is invalid");
|
||||
if (
|
||||
request.invalidates !== undefined &&
|
||||
(!Array.isArray(request.invalidates) ||
|
||||
request.invalidates.length > 32 ||
|
||||
request.invalidates.some((id) => !validId(id)))
|
||||
)
|
||||
bad("Widget invalidation targets are invalid");
|
||||
if (
|
||||
request.cacheTtlSeconds !== undefined &&
|
||||
(!Number.isInteger(request.cacheTtlSeconds) ||
|
||||
request.cacheTtlSeconds < 0 ||
|
||||
request.cacheTtlSeconds > 3600 ||
|
||||
(request.kind === "action" && request.cacheTtlSeconds !== 0))
|
||||
)
|
||||
bad("Widget request cache duration is invalid");
|
||||
if (request.staticHeaders !== undefined) {
|
||||
if (!isObject(request.staticHeaders) || Object.keys(request.staticHeaders).length > 32)
|
||||
bad("Widget static headers are invalid");
|
||||
for (const [name, value] of Object.entries(request.staticHeaders)) {
|
||||
if (!validHeaderName(name) || typeof value !== "string" || value.length > 8192)
|
||||
bad("Widget static headers are invalid");
|
||||
if ((SENSITIVE_KEY.test(name) && value.trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(value))
|
||||
bad("Widget static headers must not contain credentials");
|
||||
}
|
||||
}
|
||||
const used = new Set();
|
||||
const placeholderPattern = /\{([^{}]+)\}/g;
|
||||
let placeholder;
|
||||
while ((placeholder = placeholderPattern.exec(request.pathTemplate)) !== null) used.add(placeholder[1]);
|
||||
collectParams(request.queryTemplate, used);
|
||||
collectParams(request.bodyTemplate, used);
|
||||
for (const name of used)
|
||||
if (!Object.prototype.hasOwnProperty.call(request.parameters, name))
|
||||
bad("Widget request uses an undeclared parameter");
|
||||
}
|
||||
|
||||
function collectParams(value, result) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectParams(item, result);
|
||||
return;
|
||||
}
|
||||
if (!isObject(value)) return;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0][0] === "$param" && typeof entries[0][1] === "string") {
|
||||
result.add(entries[0][1]);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) collectParams(entry[1], result);
|
||||
}
|
||||
|
||||
function validateOptionsMetadata(metadata, schema) {
|
||||
if (
|
||||
!isObject(metadata) ||
|
||||
Object.keys(metadata).some((key) => !["control", "placeholder", "advanced", "order", "optionsSource"].includes(key))
|
||||
)
|
||||
bad("Widget option presentation metadata is invalid");
|
||||
if (metadata.control !== undefined && !OPTION_CONTROLS.includes(metadata.control))
|
||||
bad("Widget option control is invalid");
|
||||
const controlsByType = {
|
||||
string: ["text", "textarea", "select", "date", "time", "color", "icon", "url", "timeZone", "json"],
|
||||
number: ["number", "select", "slider", "duration", "json"],
|
||||
integer: ["number", "select", "slider", "duration", "json"],
|
||||
boolean: ["switch", "select", "json"],
|
||||
array: ["multi-select", "json"],
|
||||
object: ["json"],
|
||||
null: ["json"],
|
||||
};
|
||||
if (
|
||||
metadata.control !== undefined &&
|
||||
schema.type !== undefined &&
|
||||
!controlsByType[schema.type].includes(metadata.control)
|
||||
)
|
||||
bad("Widget option control does not match its type");
|
||||
if (metadata.placeholder !== undefined && typeof metadata.placeholder !== "string")
|
||||
bad("Widget option placeholder is invalid");
|
||||
if (metadata.advanced !== undefined && typeof metadata.advanced !== "boolean")
|
||||
bad("Widget advanced option metadata is invalid");
|
||||
if (metadata.order !== undefined && (typeof metadata.order !== "number" || !Number.isFinite(metadata.order)))
|
||||
bad("Widget option order is invalid");
|
||||
if (metadata.optionsSource !== undefined) {
|
||||
const source = metadata.optionsSource;
|
||||
if (
|
||||
!isObject(source) ||
|
||||
Object.keys(source).some((key) => !["requestId", "itemsPath", "valuePath", "labelPath"].includes(key)) ||
|
||||
!["requestId", "valuePath", "labelPath"].every((key) => typeof source[key] === "string" && source[key]) ||
|
||||
(source.itemsPath !== undefined && (typeof source.itemsPath !== "string" || !source.itemsPath))
|
||||
)
|
||||
bad("Widget dynamic option source is invalid");
|
||||
if (!["select", "multi-select"].includes(metadata.control)) bad("Widget dynamic options require a select control");
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionsSchema(schema, root, requireType) {
|
||||
if (!isObject(schema) || Object.keys(schema).some((key) => !OPTION_FIELDS.includes(key)))
|
||||
bad("Widget options schema is invalid");
|
||||
if (requireType !== false && !OPTION_TYPES.includes(schema.type)) bad("Every widget option needs one supported type");
|
||||
if (schema.type !== undefined && !OPTION_TYPES.includes(schema.type)) bad("Widget option type is invalid");
|
||||
if (root && schema.type !== "object") bad("Widget options schema root must be an object");
|
||||
if (schema.type === "object" && schema.additionalProperties !== false)
|
||||
bad("Widget object options must reject unknown fields");
|
||||
for (const key of ["title", "description"])
|
||||
if (schema[key] !== undefined && typeof schema[key] !== "string") bad("Widget option labels are invalid");
|
||||
for (const key of ["minimum", "maximum", "multipleOf"])
|
||||
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
|
||||
bad("Widget numeric option constraints are invalid");
|
||||
if (schema.multipleOf !== undefined && schema.multipleOf <= 0) bad("Widget multipleOf must be positive");
|
||||
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum)
|
||||
bad("Widget option bounds are invalid");
|
||||
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
|
||||
if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || schema[key] < 0))
|
||||
bad("Widget option size constraints are invalid");
|
||||
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 100))
|
||||
bad("Widget option enum is invalid");
|
||||
if (schema.format !== undefined && (!OPTION_FORMATS.includes(schema.format) || schema.type !== "string"))
|
||||
bad("Widget option format is invalid");
|
||||
if (Array.isArray(schema.enum) && schema.enum.some((entry) => !optionMatches({ type: schema.type }, entry)))
|
||||
bad("Widget option enum values do not match their type");
|
||||
if (schema.const !== undefined && !optionMatches({ type: schema.type }, schema.const))
|
||||
bad("Widget option constant does not match its type");
|
||||
if (schema.properties !== undefined) {
|
||||
if (!isObject(schema.properties)) bad("Widget option properties are invalid");
|
||||
for (const [name, child] of Object.entries(schema.properties)) {
|
||||
if (!validBindingId(name))
|
||||
bad("Widget option names must start with a letter and contain letters, numbers, - or _");
|
||||
if (SENSITIVE_KEY.test(name)) bad("Credentials must use source authentication, not widget options");
|
||||
validateOptionsSchema(child, false, true);
|
||||
}
|
||||
if (schema.type !== undefined && schema.type !== "object") bad("Widget option properties require an object type");
|
||||
if (
|
||||
schema.required !== undefined &&
|
||||
(!Array.isArray(schema.required) ||
|
||||
schema.required.some(
|
||||
(key) => typeof key !== "string" || !Object.prototype.hasOwnProperty.call(schema.properties, key),
|
||||
))
|
||||
)
|
||||
bad("Widget required options are invalid");
|
||||
}
|
||||
if (schema.required !== undefined && !Array.isArray(schema.required)) bad("Widget required options are invalid");
|
||||
if (schema.type === "array" && schema.items === undefined) bad("Widget array options require items");
|
||||
if (schema.items !== undefined) {
|
||||
if (schema.type !== undefined && schema.type !== "array") bad("Widget option items require an array type");
|
||||
validateOptionsSchema(schema.items, false, true);
|
||||
}
|
||||
for (const branch of ["if", "then", "else"])
|
||||
if (schema[branch] !== undefined) validateOptionsSchema(schema[branch], false, branch !== "if");
|
||||
if (schema["x-homarr"] !== undefined) validateOptionsMetadata(schema["x-homarr"], schema);
|
||||
}
|
||||
|
||||
function optionMatches(schema, value) {
|
||||
if (schema.const !== undefined && value !== schema.const) return false;
|
||||
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false;
|
||||
if (schema.type === "object" && !isObject(value)) return false;
|
||||
if (schema.type === "array" && !Array.isArray(value)) return false;
|
||||
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value))) return false;
|
||||
if (schema.type === "number" && typeof value !== "number") return false;
|
||||
if (schema.type === "string" && typeof value !== "string") return false;
|
||||
if (schema.type === "boolean" && typeof value !== "boolean") return false;
|
||||
if (schema.type === "null" && value !== null) return false;
|
||||
if (isObject(schema.properties) && isObject(value)) {
|
||||
for (const [key, child] of Object.entries(schema.properties)) {
|
||||
if (value[key] !== undefined && isObject(child) && !optionMatches(child, value[key])) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateOptionsValue(schema, value) {
|
||||
if (!optionMatches(schema, value)) bad("Widget default options do not match their schema");
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(value)) bad("Widget default options do not match their schema");
|
||||
const branch =
|
||||
isObject(schema.if) && optionMatches(schema.if, value)
|
||||
? schema.then
|
||||
: isObject(schema.if)
|
||||
? schema.else
|
||||
: undefined;
|
||||
const properties = Object.assign(
|
||||
{},
|
||||
isObject(schema.properties) ? schema.properties : {},
|
||||
isObject(branch) && isObject(branch.properties) ? branch.properties : {},
|
||||
);
|
||||
const required = (Array.isArray(schema.required) ? schema.required : []).concat(
|
||||
isObject(branch) && Array.isArray(branch.required) ? branch.required : [],
|
||||
);
|
||||
for (const key of required) if (value[key] === undefined) bad("Widget default options omit a required value");
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!isObject(properties[key])) bad("Widget default options contain an unknown value");
|
||||
validateOptionsValue(properties[key], child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(value)) bad("Widget default options do not match their schema");
|
||||
if (isObject(schema.items)) for (const item of value) validateOptionsValue(schema.items, item);
|
||||
return;
|
||||
}
|
||||
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value)))
|
||||
bad("Widget default options do not match their schema");
|
||||
if (schema.type === "number" && typeof value !== "number") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "string" && typeof value !== "string") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "boolean" && typeof value !== "boolean") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "null" && value !== null) bad("Widget default options do not match their schema");
|
||||
}
|
||||
|
||||
function collectDynamicOptionRequestIds(schema, result) {
|
||||
if (!isObject(schema)) return;
|
||||
const metadata = schema["x-homarr"];
|
||||
if (isObject(metadata) && isObject(metadata.optionsSource)) result.add(metadata.optionsSource.requestId);
|
||||
if (isObject(schema.properties))
|
||||
for (const child of Object.values(schema.properties)) collectDynamicOptionRequestIds(child, result);
|
||||
if (schema.items !== undefined) collectDynamicOptionRequestIds(schema.items, result);
|
||||
for (const branch of ["if", "then", "else"])
|
||||
if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result);
|
||||
}
|
||||
|
||||
function validateDefaultState(schema, state) {
|
||||
if (!isObject(state)) bad("Widget default state is invalid");
|
||||
for (const [key, value] of Object.entries(state)) {
|
||||
const type = schema[key];
|
||||
const matches =
|
||||
type === "date"
|
||||
? typeof value === "string"
|
||||
: type === "string[]" || type === "date[]"
|
||||
? Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
||||
: type === "number[]"
|
||||
? Array.isArray(value) && value.every((entry) => typeof entry === "number")
|
||||
: typeof value === type;
|
||||
if (!type || !matches) bad("Widget default state does not match its schema");
|
||||
}
|
||||
}
|
||||
|
||||
function containsCredential(value, key) {
|
||||
if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true;
|
||||
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, ""));
|
||||
if (!isObject(value)) return false;
|
||||
if (Object.keys(value).length === 1 && typeof value.$param === "string") return false;
|
||||
return Object.entries(value).some(([childKey, child]) => containsCredential(child, childKey));
|
||||
}
|
||||
|
||||
function containsPrototypeKey(value) {
|
||||
if (Array.isArray(value)) return value.some(containsPrototypeKey);
|
||||
if (!isObject(value)) return false;
|
||||
return Object.entries(value).some(
|
||||
([key, child]) =>
|
||||
["__proto__", "prototype", "constructor"].includes(key.toLowerCase()) || containsPrototypeKey(child),
|
||||
);
|
||||
}
|
||||
|
||||
function validateTemplate(template) {
|
||||
if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid");
|
||||
if (
|
||||
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s)/i.test(
|
||||
template,
|
||||
)
|
||||
)
|
||||
bad("Widget template requests a blocked capability");
|
||||
}
|
||||
|
||||
function validateTemplateRequests(template, requests) {
|
||||
const byId = new Map(requests.map((request) => [request.id, request]));
|
||||
const componentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
|
||||
let match;
|
||||
while ((match = componentPattern.exec(template)) !== null) {
|
||||
const idMatch = match[2].match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
|
||||
const requestId = idMatch && (idMatch[1] || idMatch[2]);
|
||||
if (!requestId) bad(`${match[1]} must use a literal requestId`);
|
||||
const request = byId.get(requestId);
|
||||
const expectedKind = match[1] === "SubFetch" ? "query" : "action";
|
||||
if (!request || request.kind !== expectedKind) bad(`${match[1]} references an invalid request`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateStateBindings(template, stateSchema) {
|
||||
const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g;
|
||||
let match;
|
||||
while ((match = bindingPattern.exec(template)) !== null) {
|
||||
const stateName = match[1] || match[2];
|
||||
if (!stateName) bad("bind must use a literal state name");
|
||||
if (!Object.prototype.hasOwnProperty.call(stateSchema, stateName))
|
||||
bad(`bind references unknown state '${stateName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSubmission(record) {
|
||||
const content = record.getString("content");
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
bad("Widget content is not valid JSON");
|
||||
}
|
||||
if (
|
||||
!isObject(widget) ||
|
||||
widget.$schema !== WIDGET_SCHEMA ||
|
||||
typeof widget.name !== "string" ||
|
||||
!widget.name.trim() ||
|
||||
widget.name.length > 128 ||
|
||||
(widget.description !== undefined && (typeof widget.description !== "string" || widget.description.length > 512)) ||
|
||||
(widget.iconUrl !== undefined && (!validHttpUrl(widget.iconUrl) || /^https?:\/\/[^/]*@/i.test(widget.iconUrl))) ||
|
||||
Object.keys(widget).some((key) => !TOP_LEVEL_FIELDS.includes(key)) ||
|
||||
!Array.isArray(widget.sources) ||
|
||||
widget.sources.length < 1 ||
|
||||
widget.sources.length > 8 ||
|
||||
!Array.isArray(widget.requests) ||
|
||||
widget.requests.length > 64 ||
|
||||
!isObject(widget.defaultOptions)
|
||||
)
|
||||
bad("Widget does not match homarr-custom-widget-v2");
|
||||
validateOptionsSchema(widget.optionsSchema, true, true);
|
||||
validateOptionsValue(widget.optionsSchema, widget.defaultOptions);
|
||||
if (
|
||||
widget.iconUrl &&
|
||||
/[?&](?:authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)=/i.test(
|
||||
widget.iconUrl,
|
||||
)
|
||||
)
|
||||
bad("Widget icon URLs must not contain credentials");
|
||||
const sourceIds = new Set();
|
||||
for (const source of widget.sources) {
|
||||
validateSource(source);
|
||||
if (sourceIds.has(source.id)) bad("Widget source IDs must be unique");
|
||||
sourceIds.add(source.id);
|
||||
}
|
||||
const requestIds = new Set();
|
||||
for (const request of widget.requests) {
|
||||
validateRequest(request, sourceIds);
|
||||
if (requestIds.has(request.id)) bad("Widget request IDs must be unique");
|
||||
requestIds.add(request.id);
|
||||
}
|
||||
for (const request of widget.requests)
|
||||
for (const target of request.invalidates || []) {
|
||||
const targetRequest = widget.requests.find((candidate) => candidate.id === target);
|
||||
if (!targetRequest || targetRequest.kind !== "query") bad("Widget invalidates an unknown or non-query request");
|
||||
}
|
||||
for (const request of widget.requests) {
|
||||
if (request.auth !== "inherit") continue;
|
||||
const source = widget.sources.find((candidate) => candidate.id === request.sourceId);
|
||||
if (
|
||||
source.auth.type === "apiKeyHeader" &&
|
||||
Object.keys(request.staticHeaders || {}).some(
|
||||
(name) => name.toLowerCase() === source.auth.headerName.toLowerCase(),
|
||||
)
|
||||
)
|
||||
bad("Widget request overrides source authentication");
|
||||
if (
|
||||
source.auth.type === "apiKeyQuery" &&
|
||||
Object.keys(request.queryTemplate || {}).some(
|
||||
(name) => name.toLowerCase() === source.auth.parameterName.toLowerCase(),
|
||||
)
|
||||
)
|
||||
bad("Widget request overrides source authentication");
|
||||
}
|
||||
const dynamicOptionRequestIds = new Set();
|
||||
collectDynamicOptionRequestIds(widget.optionsSchema, dynamicOptionRequestIds);
|
||||
for (const requestId of dynamicOptionRequestIds) {
|
||||
const request = widget.requests.find((candidate) => candidate.id === requestId);
|
||||
if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request");
|
||||
}
|
||||
if (
|
||||
widget.stateSchema !== undefined &&
|
||||
(!isObject(widget.stateSchema) ||
|
||||
Object.entries(widget.stateSchema).some(
|
||||
([key, type]) =>
|
||||
!validBindingId(key) ||
|
||||
SENSITIVE_KEY.test(key) ||
|
||||
!["string", "number", "boolean", "date", "string[]", "number[]", "date[]"].includes(type),
|
||||
))
|
||||
)
|
||||
bad("Widget state schema is invalid");
|
||||
if (widget.defaultState !== undefined) validateDefaultState(widget.stateSchema || {}, widget.defaultState);
|
||||
validateTemplate(widget.template);
|
||||
validateTemplateRequests(widget.template, widget.requests);
|
||||
validateTemplateStateBindings(widget.template, widget.stateSchema || {});
|
||||
for (const request of widget.requests) {
|
||||
for (const [name, value] of Object.entries(request.staticHeaders || {})) {
|
||||
if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value)))
|
||||
bad("Widget static headers must not contain credentials");
|
||||
}
|
||||
}
|
||||
if (
|
||||
CREDENTIAL_TEXT.test(widget.template) ||
|
||||
containsCredential(widget.defaultOptions, "") ||
|
||||
containsCredential(widget.defaultState, "") ||
|
||||
widget.requests.some(
|
||||
(request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""),
|
||||
)
|
||||
)
|
||||
bad("Widget exports must not contain credentials");
|
||||
if (
|
||||
containsPrototypeKey(widget.optionsSchema) ||
|
||||
containsPrototypeKey(widget.defaultOptions) ||
|
||||
containsPrototypeKey(widget.defaultState) ||
|
||||
widget.requests.some(
|
||||
(request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate),
|
||||
)
|
||||
)
|
||||
bad("Widget contains a prototype-pollution key");
|
||||
record.set("contentHash", $security.sha256(content));
|
||||
}
|
||||
|
||||
function isAdmin(app, userId) {
|
||||
try {
|
||||
app.findFirstRecordByFilter("workshop_admins", "user = {:user}", { user: userId });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(record) {
|
||||
const data = record.publicExport();
|
||||
if (typeof data.content === "string") {
|
||||
data.contentHash = $security.sha256(data.content);
|
||||
delete data.content;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
function audit(app, actor, action, values) {
|
||||
const record = new Record(app.findCollectionByNameOrId("workshop_admin_actions"));
|
||||
record.set("actor", actor.id);
|
||||
record.set("action", action);
|
||||
Object.entries(values).forEach(([key, value]) => record.set(key, value));
|
||||
app.save(record);
|
||||
}
|
||||
|
||||
module.exports = { audit, isAdmin, snapshot, validateSubmission };
|
||||
@@ -0,0 +1,78 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
utils.validateSubmission(e.record);
|
||||
const user = e.app.findRecordById("users", e.auth.id);
|
||||
e.record.set("author", e.auth.id);
|
||||
e.record.set("authorName", user.getString("displayName"));
|
||||
e.record.set("revision", 1);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = e.app.findRecordById("submissions", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("authorName", original.get("authorName"));
|
||||
e.record.set("revision", original.getInt("revision") + 1);
|
||||
utils.validateSubmission(e.record);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordDeleteRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const adminDelete = e.auth && utils.isAdmin(e.app, e.auth.id) && e.record.getString("author") !== e.auth.id;
|
||||
const snapshot = adminDelete ? utils.snapshot(e.record) : "";
|
||||
e.next();
|
||||
if (adminDelete)
|
||||
utils.audit(e.app, e.auth, "delete_submission", {
|
||||
submissionId: e.record.id,
|
||||
reason: "Deleted by Workshop administrator",
|
||||
snapshot,
|
||||
});
|
||||
}, "submissions");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", e.auth.id);
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const original = e.app.findRecordById("votes", e.record.id);
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", original.get("user"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
e.record.set("reporter", e.auth.id);
|
||||
e.record.set("status", "open");
|
||||
e.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = e.app.findRecordById("reports", e.record.id);
|
||||
if (e.record.getString("status") !== "dismissed") throw new BadRequestError("Reports can only be dismissed");
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.record.set("reporter", original.get("reporter"));
|
||||
e.record.set("category", original.get("category"));
|
||||
e.record.set("explanation", original.get("explanation"));
|
||||
e.next();
|
||||
if (original.getString("status") !== "dismissed") {
|
||||
utils.audit(e.app, e.auth, "dismiss_report", {
|
||||
submissionId: original.getString("submission"),
|
||||
reportId: original.id,
|
||||
reason: e.record.getString("dismissalReason") || "Dismissed by Workshop administrator",
|
||||
snapshot: utils.snapshot(original),
|
||||
});
|
||||
}
|
||||
}, "reports");
|
||||
@@ -0,0 +1,211 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
let users;
|
||||
try {
|
||||
users = app.findCollectionByNameOrId("users");
|
||||
} catch {
|
||||
users = new Collection({ type: "auth", name: "users" });
|
||||
}
|
||||
users.passwordAuth = { enabled: false };
|
||||
if (!users.fields.getByName("displayName"))
|
||||
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
|
||||
app.save(users);
|
||||
|
||||
const admins = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_admins",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
],
|
||||
});
|
||||
admins.addIndex("idx_workshop_admin_user", true, "user", "");
|
||||
app.save(admins);
|
||||
const adminRule = "@collection.workshop_admins.user ?= @request.auth.id";
|
||||
users.listRule = adminRule;
|
||||
users.viewRule = `id = @request.auth.id || ${adminRule}`;
|
||||
users.updateRule = "id = @request.auth.id";
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
name: "submissions",
|
||||
listRule: adminRule,
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
|
||||
updateRule: "author = @request.auth.id",
|
||||
deleteRule: `author = @request.auth.id || ${adminRule}`,
|
||||
fields: [
|
||||
{ type: "text", name: "title", required: true, min: 3, max: 100 },
|
||||
{ type: "text", name: "description", max: 2000 },
|
||||
{ type: "text", name: "content", required: true, max: 1000000 },
|
||||
{ type: "text", name: "contentHash", required: true, max: 64 },
|
||||
{
|
||||
type: "file",
|
||||
name: "screenshots",
|
||||
maxSelect: 5,
|
||||
maxSize: 5242880,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
thumbs: ["480x320", "960x640"],
|
||||
},
|
||||
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "text", name: "authorName", required: true, max: 100 },
|
||||
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
|
||||
{ type: "text", name: "changelog", max: 2000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
submissions.addIndex("idx_submissions_author", false, "author", "");
|
||||
submissions.addIndex("idx_submissions_created", false, "created", "");
|
||||
app.save(submissions);
|
||||
|
||||
const votes = new Collection({
|
||||
type: "base",
|
||||
name: "votes",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "@request.auth.id != '' && @request.body.user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "submission",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
|
||||
votes.addIndex("idx_votes_submission", false, "submission", "");
|
||||
app.save(votes);
|
||||
|
||||
const reports = new Collection({
|
||||
type: "base",
|
||||
name: "reports",
|
||||
listRule: adminRule,
|
||||
viewRule: `reporter = @request.auth.id || ${adminRule}`,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: adminRule,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "submission",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
type: "relation",
|
||||
name: "reporter",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "category",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
|
||||
},
|
||||
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
|
||||
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "dismissed"] },
|
||||
{ type: "text", name: "dismissalReason", max: 1000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
|
||||
reports.addIndex("idx_reports_status_created", false, "status, created", "");
|
||||
app.save(reports);
|
||||
|
||||
const actions = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_admin_actions",
|
||||
listRule: adminRule,
|
||||
viewRule: adminRule,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
|
||||
{
|
||||
type: "select",
|
||||
name: "action",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["delete_submission", "dismiss_report"],
|
||||
},
|
||||
{ type: "text", name: "submissionId", max: 30 },
|
||||
{ type: "text", name: "reportId", max: 30 },
|
||||
{ type: "text", name: "reason", max: 1000 },
|
||||
{ type: "text", name: "snapshot", max: 100000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
],
|
||||
});
|
||||
actions.addIndex("idx_workshop_admin_actions_created", false, "created", "");
|
||||
app.save(actions);
|
||||
|
||||
const listings = new Collection({
|
||||
type: "view",
|
||||
name: "workshop_listings",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT s.id, s.title, s.description, s.contentHash, s.screenshots,
|
||||
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
|
||||
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
|
||||
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
|
||||
COALESCE(SUM(v.value), 0) AS score
|
||||
FROM submissions s LEFT JOIN votes v ON v.submission = s.id GROUP BY s.id
|
||||
`,
|
||||
});
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
(rule) => !["submissions:create", "votes:create", "reports:create"].includes(rule.label),
|
||||
),
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
];
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
for (const name of [
|
||||
"workshop_listings",
|
||||
"workshop_admin_actions",
|
||||
"reports",
|
||||
"votes",
|
||||
"submissions",
|
||||
"workshop_admins",
|
||||
]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
WORKSHOP_TEST_DATA_DIR=$(mktemp -d)
|
||||
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
|
||||
|
||||
cleanup() {
|
||||
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -f apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
|
||||
rm -rf "$WORKSHOP_TEST_DATA_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -f apps/workshop/docker-compose.yml up --build -d
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 60 ]; then
|
||||
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -f apps/workshop/docker-compose.yml logs --no-color workshop
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker exec homarr-workshop-workshop-1 pocketbase superuser create \
|
||||
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
|
||||
@@ -0,0 +1,249 @@
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
|
||||
|
||||
const request = async (path, init = {}) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
|
||||
return body;
|
||||
};
|
||||
|
||||
const expectStatus = async (path, init, expected) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
if (response.status !== expected) throw new Error(`Expected ${expected} for ${path}, received ${response.status}`);
|
||||
};
|
||||
|
||||
const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
|
||||
});
|
||||
const rootHeaders = { authorization: `Bearer ${root.token}` };
|
||||
const collections = await request("/api/collections?perPage=200", { headers: rootHeaders });
|
||||
const collectionNames = new Set(collections.items.map((collection) => collection.name));
|
||||
for (const required of [
|
||||
"submissions",
|
||||
"votes",
|
||||
"reports",
|
||||
"workshop_admins",
|
||||
"workshop_admin_actions",
|
||||
"workshop_listings",
|
||||
]) {
|
||||
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
|
||||
}
|
||||
|
||||
// Workshop production authentication is GitHub OAuth. Password auth is enabled only in this disposable test database.
|
||||
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
|
||||
await request("/api/collections/users", {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
|
||||
});
|
||||
|
||||
const password = "WorkshopAuthor123!";
|
||||
const user = await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email: "widget-author@example.invalid",
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
displayName: "Widget Author",
|
||||
}),
|
||||
});
|
||||
await request("/api/collections/workshop_admins/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ user: user.id }),
|
||||
});
|
||||
const auth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: user.email, password }),
|
||||
});
|
||||
const userHeaders = { authorization: `Bearer ${auth.token}` };
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Workshop runtime probe",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
name: "API",
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
auth: { type: "none" },
|
||||
},
|
||||
],
|
||||
requests: [],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
stateSchema: {},
|
||||
defaultState: {},
|
||||
template: "<Text>Runtime probe</Text>",
|
||||
};
|
||||
const submission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Workshop runtime probe",
|
||||
description: "PocketBase integration test",
|
||||
content: JSON.stringify(widget),
|
||||
changelog: "Initial",
|
||||
author: user.id,
|
||||
}),
|
||||
});
|
||||
if (submission.revision !== 1 || submission.authorName !== "Widget Author" || submission.contentHash.length !== 64) {
|
||||
throw new Error("Submission hooks did not normalize publication metadata");
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Removed format",
|
||||
content: JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } }),
|
||||
author: user.id,
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Unsafe authentication header",
|
||||
content: JSON.stringify({
|
||||
...widget,
|
||||
sources: [{ ...widget.sources[0], auth: { type: "apiKeyHeader", headerName: "X-Forwarded-Authorization" } }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Unknown template request",
|
||||
content: JSON.stringify({ ...widget, template: '<SubFetch requestId="missing" />' }),
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const updatedWidget = { ...widget, description: "Updated definition" };
|
||||
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ content: JSON.stringify(updatedWidget), changelog: "Updated" }),
|
||||
});
|
||||
if (updatedSubmission.revision !== 2) throw new Error("Content edits must increment the submission revision");
|
||||
|
||||
await request("/api/collections/votes/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
|
||||
});
|
||||
await expectStatus(
|
||||
"/api/collections/votes/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
|
||||
},
|
||||
400,
|
||||
);
|
||||
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
|
||||
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0) {
|
||||
throw new Error("Workshop listing vote totals are incorrect");
|
||||
}
|
||||
|
||||
const report = await request("/api/collections/reports/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: user.id,
|
||||
category: "other",
|
||||
explanation: "Runtime moderation test",
|
||||
status: "dismissed",
|
||||
}),
|
||||
});
|
||||
const reports = await request("/api/collections/reports/records?filter=status%3D%27open%27", {
|
||||
headers: userHeaders,
|
||||
});
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== user.id) {
|
||||
throw new Error("Workshop administrators cannot inspect open reports and reporters");
|
||||
}
|
||||
|
||||
await request(`/api/collections/reports/records/${report.id}`, {
|
||||
method: "PATCH",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ status: "dismissed", dismissalReason: "Runtime test complete" }),
|
||||
});
|
||||
const actions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
|
||||
if (!actions.items.some((action) => action.action === "dismiss_report" && action.reportId === report.id)) {
|
||||
throw new Error("Workshop report dismissal was not audited");
|
||||
}
|
||||
|
||||
const authorPassword = "WorkshopSecondAuthor123!";
|
||||
const secondAuthor = await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email: "second-widget-author@example.invalid",
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password: authorPassword,
|
||||
passwordConfirm: authorPassword,
|
||||
displayName: "Second Author",
|
||||
}),
|
||||
});
|
||||
const secondAuth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: secondAuthor.email, password: authorPassword }),
|
||||
});
|
||||
const secondHeaders = { authorization: `Bearer ${secondAuth.token}` };
|
||||
const secondSubmission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: secondHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Administrator deletion probe",
|
||||
content: JSON.stringify(widget),
|
||||
author: secondAuthor.id,
|
||||
}),
|
||||
});
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "DELETE", headers: secondHeaders },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${secondSubmission.id}`,
|
||||
{ method: "DELETE", headers: userHeaders },
|
||||
204,
|
||||
);
|
||||
const deletionActions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
|
||||
if (
|
||||
!deletionActions.items.some(
|
||||
(action) => action.action === "delete_submission" && action.submissionId === secondSubmission.id,
|
||||
)
|
||||
) {
|
||||
throw new Error("Workshop administrator deletion was not audited");
|
||||
}
|
||||
|
||||
console.log("Workshop PocketBase integration passed");
|
||||
@@ -35,6 +35,7 @@
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project dom --coverage.enabled",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e",
|
||||
"test:workshop": "sh apps/workshop/test.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./open-api": "./src/open-api.ts",
|
||||
"./mcp": "./src/mcp.ts"
|
||||
"./mcp": "./src/mcp.ts",
|
||||
"./custom-widget-secrets": "./src/router/custom-widget/secret-requests.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
interface CacheVersionDefinition {
|
||||
sources: string;
|
||||
requests: string;
|
||||
secrets: Array<{ sourceId: string; kind: string; encryptedValue: string }>;
|
||||
}
|
||||
|
||||
export function getCustomWidgetCacheVersion(definition: CacheVersionDefinition) {
|
||||
const secrets = definition.secrets
|
||||
.map(({ sourceId, kind, encryptedValue }) => ({ sourceId, kind, encryptedValue }))
|
||||
.toSorted((left, right) => `${left.sourceId}:${left.kind}`.localeCompare(`${right.sourceId}:${right.kind}`));
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([definition.sources, definition.requests, secrets]))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq, notInArray } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetCreateSchema,
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetUpdateSchema,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -19,210 +18,156 @@ import { metadataProcedures } from "./metadata-procedures";
|
||||
import { previewActionProcedures } from "./preview-action-procedures";
|
||||
import { previewBaseProcedures } from "./preview-base-procedures";
|
||||
import { previewQueryProcedures } from "./preview-query-procedures";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
import { templateProcedures } from "./template-procedures";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { secretProcedures } from "./secret-procedures";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
|
||||
displayConfig: (value) => stringifySuperJson(value),
|
||||
};
|
||||
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
...metadataProcedures,
|
||||
|
||||
...managementQueryProcedures,
|
||||
|
||||
create: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a validated custom widget. Admin only. Call customWidget_schema and customWidget_validate first. For Custom JSX use jsxApiVersion 2, named requests, a GET base method, and no inline credentials.",
|
||||
},
|
||||
})
|
||||
create: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
|
||||
.input(customWidgetCreateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { secrets, ...candidate } = input;
|
||||
if (secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Writing custom widget secrets requires dedicated permission",
|
||||
});
|
||||
}
|
||||
const definition = customWidgetDefinitionSchema.parse(candidate);
|
||||
assertSecretSources(definition.sources, secrets);
|
||||
const id = createId();
|
||||
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
url: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
method: input.method,
|
||||
requestBody: input.requestBody,
|
||||
displayType: input.displayType,
|
||||
displayConfig: stringifySuperJson(input.displayConfig),
|
||||
...serializeCustomWidgetDefinition(definition),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
if (input.secrets.length > 0) {
|
||||
if (secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
secrets.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Created custom widget definition", { id, name: input.name });
|
||||
logger.info("Created custom widget definition", { id, name: definition.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update an existing custom widget. Admin only. REQUIRED: id. Read it with customWidget_byId, preserve unrelated fields, validate the resulting complete draft, then send the changed fields. Omit secrets to preserve stored credentials.",
|
||||
},
|
||||
})
|
||||
update: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
|
||||
.input(customWidgetUpdateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const effectiveDisplayConfig =
|
||||
input.displayConfig ??
|
||||
parseDisplayConfig(existing.displayConfig, input.id, logger, "Corrupt displayConfig in custom widget update");
|
||||
const effectiveMethod = input.method ?? existing.method;
|
||||
if (customJsxDisplayConfigV2Schema.safeParse(effectiveDisplayConfig).success && effectiveMethod !== "GET") {
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const { id, secrets, ...changes } = input;
|
||||
if (secrets?.length && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
|
||||
code: "FORBIDDEN",
|
||||
message: "Writing custom widget secrets requires dedicated permission",
|
||||
});
|
||||
}
|
||||
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
||||
if (secrets) assertSecretSources(definition.sources, secrets);
|
||||
|
||||
const { id, secrets, ...updateFields } = input;
|
||||
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, id));
|
||||
|
||||
for (const [key, value] of Object.entries(updateFields)) {
|
||||
if (value === undefined) continue;
|
||||
const serialize = updateFieldSerializers[key];
|
||||
if (serialize) {
|
||||
updateValues[key] = serialize(value);
|
||||
} else {
|
||||
updateValues[key] = value;
|
||||
}
|
||||
await ctx.db.delete(customWidgetSecrets).where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
notInArray(
|
||||
customWidgetSecrets.sourceId,
|
||||
definition.sources.map((source) => source.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of definition.sources) {
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where);
|
||||
}
|
||||
|
||||
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
|
||||
|
||||
if (secrets !== undefined) {
|
||||
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
|
||||
|
||||
if (secrets.length > 0) {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
definitionId: id,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
} else if (
|
||||
effectiveAuthType === "none" ||
|
||||
(typeof updateFields.authType === "string" && updateFields.authType !== existing.authType)
|
||||
) {
|
||||
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
|
||||
for (const secret of secrets) {
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await ctx.db.insert(customWidgetSecrets).values({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
}),
|
||||
|
||||
toggleEnabled: adminProcedure
|
||||
...secretProcedures,
|
||||
|
||||
toggleEnabled: manageProcedure
|
||||
.input(z.object({ id: z.string(), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: input.enabled, updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
delete: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
|
||||
logger.info("Deleted custom widget definition", { id: input.id });
|
||||
}),
|
||||
|
||||
duplicate: manageProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
|
||||
logger.info("Deleted custom widget definition", { id: input.id });
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
return { id, name: `${current.name} (copy)` };
|
||||
}),
|
||||
|
||||
...templateProcedures,
|
||||
|
||||
...transferProcedures,
|
||||
|
||||
...previewBaseProcedures,
|
||||
...previewQueryProcedures,
|
||||
...previewActionProcedures,
|
||||
|
||||
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id: newId,
|
||||
name: `${definition.name} (copy)`,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName,
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody,
|
||||
displayType: definition.displayType,
|
||||
displayConfig: definition.displayConfig,
|
||||
enabled: definition.enabled,
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
if (definition.secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: s.value,
|
||||
definitionId: newId,
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Duplicated custom widget definition", { sourceId: input.id, newId });
|
||||
return { id: newId, name: `${definition.name} (copy)` };
|
||||
}),
|
||||
});
|
||||
@@ -1,51 +1,51 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { eq, or } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getCustomWidgetCacheVersion } from "./cache-version";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
|
||||
export const managementQueryProcedures = {
|
||||
all: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
|
||||
},
|
||||
})
|
||||
list: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
|
||||
.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
return definitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
method: definition.method,
|
||||
displayType: definition.displayType,
|
||||
authType: definition.authType,
|
||||
enabled: definition.enabled,
|
||||
}));
|
||||
return definitions.map((definition) => {
|
||||
const widget = parseStoredCustomWidgetDefinition(definition);
|
||||
return {
|
||||
id: definition.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
iconUrl: widget.iconUrl,
|
||||
sources: widget.sources.map(({ id, name, baseUrl, networkScope, auth }) => ({
|
||||
id,
|
||||
name,
|
||||
origin: new URL(baseUrl).origin,
|
||||
networkScope,
|
||||
authType: auth.type,
|
||||
})),
|
||||
requestCount: widget.requests.length,
|
||||
defaultOptions: widget.defaultOptions,
|
||||
updatedAt: definition.updatedAt,
|
||||
enabled: definition.enabled,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
byId: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
|
||||
},
|
||||
})
|
||||
get: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
@@ -54,14 +54,13 @@ export const managementQueryProcedures = {
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
return {
|
||||
...definition,
|
||||
displayConfig: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
),
|
||||
id: definition.id,
|
||||
...parseStoredCustomWidgetDefinition(definition),
|
||||
enabled: definition.enabled,
|
||||
createdAt: definition.createdAt,
|
||||
updatedAt: definition.updatedAt,
|
||||
secrets: definition.secrets.map((secret) => ({
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
hasValue: true,
|
||||
updatedAt: secret.updatedAt,
|
||||
@@ -73,12 +72,114 @@ export const managementQueryProcedures = {
|
||||
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
return ctx.db.query.customWidgetDefinitions.findMany({
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
where: input.currentId
|
||||
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
|
||||
: eq(customWidgetDefinitions.enabled, true),
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
columns: { id: true, name: true, description: true, iconUrl: true, displayType: true },
|
||||
});
|
||||
return definitions.map((definition) => {
|
||||
const widget = parseStoredCustomWidgetDefinition(definition);
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
optionsSchema: widget.optionsSchema,
|
||||
defaultOptions: widget.defaultOptions,
|
||||
template: widget.template,
|
||||
stateSchema: widget.stateSchema ?? {},
|
||||
defaultState: widget.defaultState ?? {},
|
||||
sources: widget.sources.map(({ id, name, networkScope, auth }) => ({
|
||||
id,
|
||||
name,
|
||||
networkScope,
|
||||
authType: auth.type,
|
||||
})),
|
||||
requestCapabilities: widget.requests.map(
|
||||
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission,
|
||||
confirmation,
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
optionRequests: widget.requests
|
||||
.filter((request) => request.kind === "query")
|
||||
.map(({ id, parameters }) => ({ id, parameters })),
|
||||
updatedAt: definition.updatedAt,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
optionRequest: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
boardId: z.string(),
|
||||
definitionId: z.string(),
|
||||
requestId: z.string(),
|
||||
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!stored || !stored.enabled) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget not found" });
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
const request = definition.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.minimumBoardPermission);
|
||||
const source = definition.sources.find((candidate) => candidate.id === request.sourceId);
|
||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Option query source not found" });
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `configuration:${input.boardId}`,
|
||||
definitionId: input.definitionId,
|
||||
});
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: source.baseUrl,
|
||||
targetUrl: renderRequestTarget(source.baseUrl, request, input.params),
|
||||
method: request.method,
|
||||
body: renderRequestBody(request.bodyTemplate, input.params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none" || source.auth.type === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: source.auth.type,
|
||||
secrets: stored.secrets
|
||||
.filter((secret) => secret.sourceId === source.id)
|
||||
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
|
||||
headerName:
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? source.auth.headerName
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? source.auth.parameterName
|
||||
: undefined,
|
||||
},
|
||||
networkScope: source.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds ?? 30,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -1,41 +1,38 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
customWidgetImportSchema,
|
||||
getCustomWidgetJsonSchema,
|
||||
getCustomWidgetSkill,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
let schemaCache: Record<string, unknown> | undefined;
|
||||
|
||||
function getImportJsonSchema() {
|
||||
schemaCache ??= {
|
||||
...z.toJSONSchema(customWidgetImportSchema),
|
||||
title: "Homarr Custom Widget",
|
||||
description:
|
||||
"Schema for importing/exporting custom widget definitions in Homarr. All jsonPath fields use JSONPath syntax. The displayConfig must match displayType. Secrets are configured separately after import.",
|
||||
};
|
||||
return schemaCache;
|
||||
}
|
||||
|
||||
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
|
||||
export const metadataProcedures = {
|
||||
schema: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
|
||||
},
|
||||
})
|
||||
.query(() => getImportJsonSchema()),
|
||||
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
|
||||
.query(() => getCustomWidgetJsonSchema()),
|
||||
|
||||
validate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate a complete custom-widget JSON draft without saving or making network requests. Returns structured issue paths so an agent can correct and validate again.",
|
||||
},
|
||||
})
|
||||
getAuthoringPrompt: publicProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
|
||||
.query(() => ({
|
||||
version: 2,
|
||||
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
resources: [
|
||||
"homarr://custom-widgets/schema",
|
||||
"homarr://custom-widgets/components",
|
||||
"homarr://custom-widgets/skill",
|
||||
],
|
||||
})),
|
||||
|
||||
getSkill: publicProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
|
||||
.query(() => getCustomWidgetSkill()),
|
||||
|
||||
validate: manageProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.query(({ input }) => {
|
||||
const result = customWidgetImportSchema.safeParse(input.widget);
|
||||
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user