refactor(custom-widgets): extract domain package
This commit is contained in:
167 files changed
+12282
-10033
No files matched your search
@@ -0,0 +1,535 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
sidebar_label: AI and MCP authoring
|
||||
---
|
||||
|
||||
# AI and MCP authoring
|
||||
|
||||
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.
|
||||
|
||||
## Recommended loop
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Template format
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
sidebar_label: Authoring workbench
|
||||
---
|
||||
|
||||
# Authoring workbench
|
||||
|
||||
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.
|
||||
|
||||
## Choose a display
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Test and preview
|
||||
|
||||
The preview has four tabs:
|
||||
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
## Keyboard and clipboard
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
sidebar_label: Custom JSX language
|
||||
---
|
||||
|
||||
# Custom JSX language
|
||||
|
||||
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:
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Components and props
|
||||
|
||||
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.
|
||||
|
||||
`SubData` is a convenience for simple response paths. Prefer the render-child pattern for rich nested results:
|
||||
|
||||
```jsx
|
||||
<SubFetch requestId="detail" params={{ id: data.selectedId }}>
|
||||
{(detail, meta) => (
|
||||
<Stack>
|
||||
<Title>{detail.name}</Title>
|
||||
<Text>HTTP {meta.status}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</SubFetch>
|
||||
```
|
||||
|
||||
## Safe helpers and budgets
|
||||
|
||||
Bindings include restricted conversion, string, array, object, JSON, math, URI, and pure date helpers. Native `Date` objects and native prototypes are not exposed.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
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.
|
||||
@@ -12,6 +12,17 @@ import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-com
|
||||
|
||||
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.
|
||||
|
||||
## Guides
|
||||
|
||||
- [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.
|
||||
|
||||
:::caution
|
||||
|
||||
A custom widget can expose data or perform actions with the credentials configured for it. Import definitions only from sources you trust and review every network capability before using the widget on a shared board.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
sidebar_label: Requests and security
|
||||
---
|
||||
|
||||
# Named requests and security
|
||||
|
||||
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.
|
||||
|
||||
## Capability kinds
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
## Parameter substitution
|
||||
|
||||
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.
|
||||
|
||||
## Network boundary
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
sidebar_label: Troubleshooting
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## The preview says the template is required
|
||||
|
||||
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.
|
||||
|
||||
## A prop is ignored
|
||||
|
||||
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.
|
||||
|
||||
## A request does not run
|
||||
|
||||
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.
|
||||
|
||||
## Network scope errors
|
||||
|
||||
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.
|
||||
|
||||
## Permission errors
|
||||
|
||||
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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,7 @@
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/custom-widgets": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"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="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
|
||||
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
{t("link")}
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
interface CodeEditorDiagnosticsProps {
|
||||
diagnostics: EditorDiagnostic[];
|
||||
editorView: EditorView | null;
|
||||
title: string;
|
||||
formatDiagnostic: (diagnostic: EditorDiagnostic) => string;
|
||||
}
|
||||
|
||||
export function CodeEditorDiagnostics({
|
||||
diagnostics,
|
||||
editorView,
|
||||
title,
|
||||
formatDiagnostic,
|
||||
}: CodeEditorDiagnosticsProps) {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={title}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">{formatDiagnostic(diagnostic)}</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -8,37 +8,15 @@ import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
Loader,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowBackUp,
|
||||
IconArrowForwardUp,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconComponents,
|
||||
IconFileCode,
|
||||
IconSearch,
|
||||
IconWand,
|
||||
} from "@tabler/icons-react";
|
||||
import { enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { 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 { validateCustomJsxTemplate } from "@homarr/validation/custom-jsx-template";
|
||||
|
||||
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
|
||||
@@ -50,43 +28,6 @@ const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => mo
|
||||
),
|
||||
});
|
||||
|
||||
export const CUSTOM_JSX_TEMPLATE_LIMIT = 50_000;
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
| "ast"
|
||||
| "dangerousProperty"
|
||||
| "dynamicRequestId"
|
||||
| "eventHandler"
|
||||
| "invalidRequestManifest"
|
||||
| "legacyNetworkProps"
|
||||
| "missingRequestId"
|
||||
| "templateEmpty"
|
||||
| "templateTooLong"
|
||||
| "unknownRequest"
|
||||
| "unsafeTag";
|
||||
|
||||
export interface EditorDiagnostic {
|
||||
code: EditorDiagnosticCode;
|
||||
severity: "error" | "warning";
|
||||
line?: number;
|
||||
column?: number;
|
||||
index?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface AnalyzeJsxTemplateOptions {
|
||||
apiVersion?: 1 | 2;
|
||||
requestIds?: string[];
|
||||
}
|
||||
|
||||
const unsafeTagPattern = /<\s*\/?\s*(script|iframe|object|embed|form|style|link|meta|base)\b/giu;
|
||||
const eventHandlerPattern = /\s(on[A-Z][\w]*)\s*=/gu;
|
||||
const dangerousPropertyPattern =
|
||||
/(?:\.|\[\s*["'])(constructor|prototype|__proto__|caller|callee|arguments|bind|call|apply)(?:["']\s*\])?/giu;
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
|
||||
|
||||
const getLine = (source: string, index: number) => source.slice(0, index).split("\n").length;
|
||||
|
||||
const customJsxCompletionSource: CompletionSource = (context) => {
|
||||
const word = context.matchBefore(/[A-Z][\w.]*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
@@ -100,111 +41,6 @@ const customJsxCompletionSource: CompletionSource = (context) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function analyzeJsxTemplate(
|
||||
template: string,
|
||||
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
): EditorDiagnostic[] {
|
||||
const diagnostics: EditorDiagnostic[] = [];
|
||||
|
||||
if (template.trim().length === 0) {
|
||||
diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
if (template.length > CUSTOM_JSX_TEMPLATE_LIMIT) {
|
||||
diagnostics.push({ code: "templateTooLong", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
diagnostics.push({
|
||||
code: "ast",
|
||||
severity: diagnostic.severity,
|
||||
line: diagnostic.line,
|
||||
column: diagnostic.column,
|
||||
index: diagnostic.index,
|
||||
value: diagnostic.message,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of template.matchAll(unsafeTagPattern)) {
|
||||
diagnostics.push({
|
||||
code: "unsafeTag",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(eventHandlerPattern)) {
|
||||
if (match[1] === "onParams") continue;
|
||||
diagnostics.push({
|
||||
code: "eventHandler",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(dangerousPropertyPattern)) {
|
||||
diagnostics.push({
|
||||
code: "dangerousProperty",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
|
||||
if (apiVersion === 2) {
|
||||
const knownRequestIds = new Set(requestIds);
|
||||
for (const match of template.matchAll(networkComponentPattern)) {
|
||||
const props = match[2] ?? "";
|
||||
const line = getLine(template, match.index);
|
||||
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
|
||||
}
|
||||
|
||||
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
|
||||
if (staticRequest?.[1]) {
|
||||
if (!knownRequestIds.has(staticRequest[1])) {
|
||||
diagnostics.push({
|
||||
code: "unknownRequest",
|
||||
severity: "error",
|
||||
line,
|
||||
index: match.index,
|
||||
value: staticRequest[1],
|
||||
});
|
||||
}
|
||||
} else if (/\brequestId\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "dynamicRequestId", severity: "error", line, index: match.index });
|
||||
} else {
|
||||
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRequestManifest(value: string): unknown[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -251,10 +87,19 @@ export function CodeEditor({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const accessibilityExtension = EditorView.contentAttributes.of({
|
||||
id,
|
||||
"aria-label": label,
|
||||
});
|
||||
return language === "jsx"
|
||||
? [javascript({ jsx: true }), autocompletion({ override: [customJsxCompletionSource] }), diagnosticsExtension]
|
||||
: [json(), diagnosticsExtension];
|
||||
}, [diagnostics, language, t]);
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [customJsxCompletionSource] }),
|
||||
diagnosticsExtension,
|
||||
accessibilityExtension,
|
||||
]
|
||||
: [json(), diagnosticsExtension, accessibilityExtension];
|
||||
}, [diagnostics, id, label, language, t]);
|
||||
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
|
||||
@@ -293,6 +138,7 @@ export function CodeEditor({
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowBackUp size={14} />}
|
||||
@@ -302,6 +148,7 @@ export function CodeEditor({
|
||||
{t("action.undo")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowForwardUp size={14} />}
|
||||
@@ -310,9 +157,19 @@ export function CodeEditor({
|
||||
>
|
||||
{t("action.redo")}
|
||||
</Button>
|
||||
{language === "jsx" && <ComponentReference />}
|
||||
{language === "jsx" && (
|
||||
<ComponentReference
|
||||
messages={{
|
||||
action: t("action.components"),
|
||||
search: t("componentReference.search"),
|
||||
empty: t("componentReference.empty"),
|
||||
count: (count) => t("componentReference.count", { count }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{starter && value.trim().length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconFileCode size={14} />}
|
||||
@@ -322,6 +179,7 @@ export function CodeEditor({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconWand size={14} />}
|
||||
@@ -335,7 +193,7 @@ export function CodeEditor({
|
||||
|
||||
<div className={classes.viewport}>
|
||||
<Editor
|
||||
id={id}
|
||||
id={`${id}-root`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
@@ -397,94 +255,17 @@ export function CodeEditor({
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{diagnostics.length > 0 && (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={t("diagnostics.title")}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<CodeEditorDiagnostics
|
||||
diagnostics={diagnostics}
|
||||
editorView={editorView}
|
||||
title={t("diagnostics.title")}
|
||||
formatDiagnostic={(diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`
|
||||
}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentReference() {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const [query, setQuery] = useState("");
|
||||
const components = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return enabledCustomJsxComponents.filter(
|
||||
(component) =>
|
||||
normalizedQuery.length === 0 ||
|
||||
component.name.toLocaleLowerCase().includes(normalizedQuery) ||
|
||||
component.category.toLocaleLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Popover width={320} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
|
||||
{t("action.components")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t("componentReference.search")}
|
||||
aria-label={t("componentReference.search")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={300} type="auto">
|
||||
<Stack gap={4}>
|
||||
{components.map((component) => (
|
||||
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
|
||||
<Text size="xs" ff="monospace" fw={600}>
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{t("componentReference.empty")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("componentReference.count", { count: enabledCustomJsxComponents.length })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -5,176 +5,10 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
|
||||
|
||||
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
|
||||
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
|
||||
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
|
||||
|
||||
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
|
||||
|
||||
## Rules
|
||||
- Use \`"$schema": "homarr-custom-widget-v3"\`.
|
||||
- \`displayType\` and \`displayConfig.type\` must match.
|
||||
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
|
||||
- The base widget request must use GET. Mutations belong in named action requests.
|
||||
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
|
||||
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
|
||||
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
|
||||
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
|
||||
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
|
||||
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
|
||||
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
|
||||
- Use only components in the generated component reference below.
|
||||
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
|
||||
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
|
||||
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
|
||||
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
|
||||
|
||||
## Named request shape
|
||||
\`\`\`ts
|
||||
interface CustomJsxRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
pathTemplate: \`/\${string}\`;
|
||||
parameters: Record<string, "string" | "number" | "boolean">;
|
||||
bodyTemplate?: unknown;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth: "inherit" | "none";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
|
||||
|
||||
## SubFetch rendering
|
||||
Use a render child when a named query needs its own layout:
|
||||
|
||||
\`\`\`jsx
|
||||
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>{detail.name}</Text>
|
||||
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
|
||||
</Group>
|
||||
{detail.metrics.map((metric) =>
|
||||
<Group key={metric.name} justify="space-between">
|
||||
<Text size="sm">{metric.name}</Text>
|
||||
<Text fw={600}>{metric.value}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
function buildComponentReference() {
|
||||
const categories = new Map<string, string[]>();
|
||||
for (const component of enabledCustomJsxComponents) {
|
||||
const names = categories.get(component.category) ?? [];
|
||||
names.push(component.name);
|
||||
categories.set(component.category, names);
|
||||
}
|
||||
|
||||
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
|
||||
}
|
||||
|
||||
function buildExamples() {
|
||||
return customJsxExamples
|
||||
.map(
|
||||
(example) => `### ${example.title}
|
||||
${example.description}
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
requests: example.requests,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
\`\`\`jsx
|
||||
${example.template}
|
||||
\`\`\``,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const responseSection = rawResponse
|
||||
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
|
||||
const currentDisplayConfig = currentConfig?.displayConfig;
|
||||
const currentTemplate =
|
||||
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
|
||||
? currentDisplayConfig.template
|
||||
: null;
|
||||
const readableCurrentConfig = currentConfig
|
||||
? {
|
||||
...currentConfig,
|
||||
...(currentTemplate && typeof currentTemplate === "string"
|
||||
? {
|
||||
displayConfig: {
|
||||
...(currentDisplayConfig as Record<string, unknown>),
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const configSection = readableCurrentConfig
|
||||
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
|
||||
: "";
|
||||
|
||||
return `${PROMPT_HEADER}
|
||||
## Import schema
|
||||
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(jsonSchema, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Generated component reference
|
||||
${buildComponentReference()}
|
||||
|
||||
## Tested examples
|
||||
${buildExamples()}
|
||||
|
||||
${responseSection}
|
||||
${configSection}
|
||||
## Requested widget
|
||||
Describe what the widget should show and which interactions it needs:
|
||||
`;
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
@@ -188,7 +22,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildAiPrompt(schema, rawResponse, currentConfig));
|
||||
await navigator.clipboard.writeText(buildCustomWidgetAiPrompt(schema, rawResponse, currentConfig));
|
||||
close();
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
} catch {
|
||||
@@ -200,6 +34,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
<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}
|
||||
@@ -219,7 +54,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
<Button type="button" leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
|
||||
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { getImportReview, parseCustomWidgetClipboard } from "./_custom-widget-import-utils";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -214,80 +202,28 @@ export const ImportCustomWidgetButton = () => {
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<Modal opened={reviewOpened} onClose={closeReview} title={t("importReview.title")} centered size="lg">
|
||||
{pendingImport && review && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("importReview.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={t("importReview.name")} value={review.name} />
|
||||
<ImportFact label={t("importReview.origin")} value={review.origin} />
|
||||
<ImportFact label={t("importReview.authentication")} value={review.authType} />
|
||||
<ImportFact label={t("importReview.networkScope")} value={review.networkScope} />
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.methods")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.permissions")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{t(`preview.request.permission.${permission}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("importReview.actionWarning.title")}
|
||||
</Text>
|
||||
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeReview} disabled={importMutation.isPending}>
|
||||
{t("importReview.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => importMutation.mutate(pendingImport as never)} loading={importMutation.isPending}>
|
||||
{t("importReview.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<ImportReviewDialog
|
||||
opened={reviewOpened}
|
||||
review={review}
|
||||
pending={importMutation.isPending}
|
||||
onClose={closeReview}
|
||||
onConfirm={() => pendingImport && importMutation.mutate(pendingImport as never)}
|
||||
messages={{
|
||||
title: t("importReview.title"),
|
||||
description: t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
cancel: t("importReview.cancel"),
|
||||
confirm: t("importReview.confirm"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Code>{value || "—"}</Code>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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,238 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,194 +0,0 @@
|
||||
export interface ImportReview {
|
||||
name: string;
|
||||
origin: string;
|
||||
authType: string;
|
||||
networkScope: string;
|
||||
methods: string[];
|
||||
permissions: string[];
|
||||
hasActions: boolean;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/iu)?.[1];
|
||||
const jsxBlock = text.match(/```(?:jsx|tsx)\s*([\s\S]*?)```/iu)?.[1]?.trim();
|
||||
const value: unknown = JSON.parse(jsonBlock ?? text);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const widget = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof widget.name !== "string" ||
|
||||
typeof widget.url !== "string" ||
|
||||
typeof widget.displayType !== "string" ||
|
||||
!widget.displayConfig ||
|
||||
typeof widget.displayConfig !== "object" ||
|
||||
Array.isArray(widget.displayConfig)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (jsxBlock && widget.displayType === "customJsx") {
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
|
||||
displayConfig.template = jsxBlock;
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
|
||||
if (!value) return null;
|
||||
const displayConfig = value.displayConfig as Record<string, unknown>;
|
||||
const requests = Array.isArray(displayConfig.requests)
|
||||
? displayConfig.requests.filter(
|
||||
(request): request is Record<string, unknown> =>
|
||||
request !== null && typeof request === "object" && !Array.isArray(request),
|
||||
)
|
||||
: [];
|
||||
const baseMethod = typeof value.method === "string" ? value.method : "GET";
|
||||
const methods = [
|
||||
...new Set([
|
||||
baseMethod,
|
||||
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
|
||||
]),
|
||||
];
|
||||
const permissions = [
|
||||
...new Set(
|
||||
requests
|
||||
.map((request) => request.minimumBoardPermission)
|
||||
.filter(
|
||||
(permission): permission is string =>
|
||||
permission === "view" || permission === "modify" || permission === "full",
|
||||
),
|
||||
),
|
||||
];
|
||||
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
|
||||
|
||||
let origin = value.url;
|
||||
try {
|
||||
origin = new URL(value.url as string).origin;
|
||||
} catch {
|
||||
// The server returns the localized validation error after confirmation.
|
||||
}
|
||||
|
||||
return {
|
||||
name: value.name as string,
|
||||
origin: String(origin),
|
||||
authType: typeof value.authType === "string" ? value.authType : "none",
|
||||
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
|
||||
methods,
|
||||
permissions,
|
||||
hasActions: methods.some((method) => method !== "GET"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as typeof base.columns) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template:
|
||||
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...base, ...typeOverrides[displayType]?.() };
|
||||
}
|
||||
import { customJsxExamples } from "@homarr/definitions";
|
||||
|
||||
export const CUSTOM_JSX_STARTER =
|
||||
customJsxExamples.find((example) => example.id === "simple-metric")?.template ??
|
||||
'<Stack align="center" justify="center" h="100%"><Text size="xs" c="dimmed">Connect data to begin</Text></Stack>';
|
||||
@@ -1,78 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Component as ReactComponent, useEffect, useMemo, useState } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Code,
|
||||
Collapse,
|
||||
CopyButton,
|
||||
getTreeExpandedState,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
Tree,
|
||||
useTree,
|
||||
} from "@mantine/core";
|
||||
import type { RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClipboard,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconExternalLink,
|
||||
IconInfoCircle,
|
||||
IconNetwork,
|
||||
IconPlayerPlay,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { 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 { displayComponents } from "@homarr/widgets/custom-api/component";
|
||||
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
|
||||
|
||||
import { analyzeJsxTemplate } from "./_code-editor";
|
||||
import { PreviewWidgetPanel } from "./_preview-widget-panel";
|
||||
|
||||
interface PreviewInput {
|
||||
export interface PreviewInput {
|
||||
url: string;
|
||||
method: string;
|
||||
authType: string;
|
||||
method: CustomWidgetMethod;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
requestBody?: string;
|
||||
displayType: string;
|
||||
displayConfig: Record<string, unknown>;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
displayType: CustomWidgetDisplayType;
|
||||
displayConfig: DisplayConfig;
|
||||
secrets: Array<{ kind: CustomWidgetSecretKind; value: string }>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
interface NamedRequestSummary {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
minimumBoardPermission?: string;
|
||||
}
|
||||
|
||||
export interface PreviewFetchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -98,94 +65,6 @@ interface CustomWidgetPreviewProps {
|
||||
|
||||
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
|
||||
|
||||
function journalEntryColor(entry: { simulated: boolean; status: number | null }): string {
|
||||
if (entry.simulated) return "yellow";
|
||||
if (entry.status !== null && entry.status >= 200 && entry.status < 400) return "green";
|
||||
return "red";
|
||||
}
|
||||
|
||||
const redactUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
for (const key of url.searchParams.keys()) url.searchParams.set(key, "redacted");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getNamedRequests = (displayConfig: Record<string, unknown>): NamedRequestSummary[] => {
|
||||
if (!Array.isArray(displayConfig.requests)) return [];
|
||||
return displayConfig.requests.filter(
|
||||
(request): request is NamedRequestSummary =>
|
||||
request !== null &&
|
||||
typeof request === "object" &&
|
||||
"id" in request &&
|
||||
typeof request.id === "string" &&
|
||||
"kind" in request &&
|
||||
(request.kind === "query" || request.kind === "action") &&
|
||||
"method" in request &&
|
||||
typeof request.method === "string" &&
|
||||
"pathTemplate" in request &&
|
||||
typeof request.pathTemplate === "string",
|
||||
);
|
||||
};
|
||||
|
||||
const getLegacyMethods = (template: string) => {
|
||||
const methods = new Set<string>();
|
||||
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
|
||||
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
|
||||
const defaultMethod = match[0].startsWith("<SubFetch") ? "GET" : "POST";
|
||||
methods.add(method?.toUpperCase() ?? defaultMethod);
|
||||
}
|
||||
return [...methods];
|
||||
};
|
||||
|
||||
interface PreviewErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
retryLabel: string;
|
||||
}
|
||||
|
||||
interface PreviewErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class PreviewErrorBoundary extends ReactComponent<PreviewErrorBoundaryProps, PreviewErrorBoundaryState> {
|
||||
state: PreviewErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): PreviewErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(_error: Error, _info: ErrorInfo) {
|
||||
// The renderer exposes redacted diagnostics. Avoid logging imported template data here.
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<Card p="xl" role="alert">
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<IconAlertTriangle size={28} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fw={600} ta="center">
|
||||
{this.props.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={480}>
|
||||
{this.props.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="light" onClick={() => this.setState({ hasError: false })}>
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomWidgetPreview(props: CustomWidgetPreviewProps) {
|
||||
const t = useScopedI18n("customWidget.preview");
|
||||
return (
|
||||
@@ -216,8 +95,7 @@ function CustomWidgetPreviewContent({
|
||||
const t = useScopedI18n("customWidget");
|
||||
const editorT = useScopedI18n("customWidget.editor");
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [sampleEditorOpen, setSampleEditorOpen] = useState(false);
|
||||
const [sampleDraft, setSampleDraft] = useState("");
|
||||
const displayConfig = formValues.displayConfig as DisplayConfig & Record<string, unknown>;
|
||||
const previewSessionId = fetchResult?.previewSession?.id ?? "";
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: previewSessionId },
|
||||
@@ -225,7 +103,7 @@ function CustomWidgetPreviewContent({
|
||||
);
|
||||
|
||||
const displayData = useMemo((): Record<string, unknown> | null => {
|
||||
const { displayType, displayConfig } = formValues;
|
||||
const { displayType } = formValues;
|
||||
if (displayType === "actionButton") {
|
||||
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
|
||||
}
|
||||
@@ -238,24 +116,24 @@ function CustomWidgetPreviewContent({
|
||||
previewLiveActions: fetchResult.previewSession.liveActions,
|
||||
}
|
||||
: extracted;
|
||||
}, [fetchResult, cachedJson, formValues]);
|
||||
}, [fetchResult, cachedJson, formValues, displayConfig]);
|
||||
|
||||
const namedRequests = useMemo(() => getNamedRequests(formValues.displayConfig), [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 getLegacyMethods((formValues.displayConfig.template as string) ?? "");
|
||||
}, [formValues.displayType, formValues.displayConfig.template, namedRequests]);
|
||||
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((formValues.displayConfig.template as string) ?? "", {
|
||||
apiVersion: formValues.displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
? analyzeJsxTemplate((displayConfig.template as string) ?? "", {
|
||||
apiVersion: displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestIds: namedRequests.map((request) => request.id),
|
||||
})
|
||||
: [],
|
||||
[formValues.displayConfig, formValues.displayType, namedRequests],
|
||||
[displayConfig, formValues.displayType, namedRequests],
|
||||
);
|
||||
|
||||
const handleTest = () => {
|
||||
@@ -264,131 +142,40 @@ function CustomWidgetPreviewContent({
|
||||
onTest();
|
||||
};
|
||||
|
||||
const PREVIEW_HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
|
||||
const previewHeight = PREVIEW_HEIGHTS[previewSize] ?? 360;
|
||||
const responseJson = cachedJson == null ? "" : JSON.stringify(cachedJson, null, 2);
|
||||
const sampleError = useMemo(() => {
|
||||
if (!sampleDraft.trim()) return null;
|
||||
try {
|
||||
JSON.parse(sampleDraft);
|
||||
return null;
|
||||
} catch {
|
||||
return t("preview.response.invalidSample");
|
||||
}
|
||||
}, [sampleDraft, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sampleEditorOpen) setSampleDraft(responseJson);
|
||||
}, [responseJson, sampleEditorOpen]);
|
||||
|
||||
const openSampleEditor = () => {
|
||||
setSampleDraft(responseJson || JSON.stringify({ name: "Sample service", status: "online", value: 42 }, null, 2));
|
||||
setSampleEditorOpen(true);
|
||||
};
|
||||
|
||||
const applySampleData = () => {
|
||||
if (!sampleDraft.trim() || sampleError) return;
|
||||
onSampleDataChange?.(JSON.parse(sampleDraft) as unknown);
|
||||
setSampleEditorOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Title order={5}>{t("preview.title")}</Title>
|
||||
{httpMethods.length > 0 && (
|
||||
<Popover width={300} position="bottom" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" color="gray" leftSection={<IconNetwork size={14} />}>
|
||||
{t("preview.capabilities.interactive")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("preview.capabilities.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.capabilities.description")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{httpMethods.map((method) => (
|
||||
<Badge key={method} size="xs" color={methodColor(method)} variant="light">
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{hasNamedActions && (
|
||||
<Stack gap={4} mt="xs">
|
||||
<Switch
|
||||
label={t("preview.capabilities.liveActions")}
|
||||
description={
|
||||
fetchResult?.previewSession
|
||||
? t("preview.capabilities.liveActionsDescription")
|
||||
: t("preview.capabilities.runTestFirst")
|
||||
}
|
||||
checked={fetchResult?.previewSession?.liveActions ?? false}
|
||||
disabled={
|
||||
!fetchResult?.previewSession || !onSetPreviewLiveActions || isUpdatingPreviewActions
|
||||
}
|
||||
onChange={(event) => onSetPreviewLiveActions?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{!fetchResult?.previewSession?.liveActions && (
|
||||
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
|
||||
{t("preview.capabilities.simulated")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
disabled={formValues.method !== "GET" || !formValues.url}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{formValues.method !== "GET" && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconInfoCircle size={15} />}>
|
||||
<Text size="xs">{t("preview.mutationDisabled")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{isSampleStale && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconAlertTriangle size={15} />}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.stale.title")}
|
||||
</Text>
|
||||
<Text size="xs">{t("preview.stale.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{fetchResult && !fetchResult.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{fetchResult.error}
|
||||
</Text>
|
||||
{fetchResult.responseInfo && (
|
||||
<Badge size="xs" color="red" variant="light" mt={4}>
|
||||
{fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText}
|
||||
</Badge>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
<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>
|
||||
@@ -399,436 +186,92 @@ function CustomWidgetPreviewContent({
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={previewSize}
|
||||
onChange={setPreviewSize}
|
||||
data={(["compact", "standard", "wide"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`preview.size.${value}` as never),
|
||||
}))}
|
||||
/>
|
||||
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) ? (
|
||||
<>
|
||||
{fetchResult?.success && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={fetchResult.responseInfo ? "green" : "gray"}
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{fetchResult.responseInfo
|
||||
? `${fetchResult.responseInfo.status} ${fetchResult.responseInfo.statusText}`
|
||||
: t("preview.response.localSample")}
|
||||
</Badge>
|
||||
)}
|
||||
<Paper withBorder p="xs" h={previewHeight} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Center h={previewHeight}>
|
||||
<Stack gap={4} align="center" maw={280}>
|
||||
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("preview.empty.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
<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">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{responseJson ? t("preview.response.sampleHint") : t("preview.response.empty")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{onSampleDataChange && (
|
||||
<Button size="compact-xs" variant="light" onClick={openSampleEditor}>
|
||||
{responseJson ? t("preview.response.editSample") : t("preview.response.addSample")}
|
||||
</Button>
|
||||
)}
|
||||
{responseJson && (
|
||||
<CopyButton value={responseJson}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("preview.response.copied") : t("preview.response.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse expanded={sampleEditorOpen}>
|
||||
<Paper p="sm" bg="var(--mantine-color-default-hover)">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label={t("preview.response.sampleLabel")}
|
||||
description={t("preview.response.sampleDescription")}
|
||||
value={sampleDraft}
|
||||
onChange={(event) => setSampleDraft(event.currentTarget.value)}
|
||||
error={sampleError}
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={18}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="xs" onClick={() => setSampleEditorOpen(false)}>
|
||||
{t("preview.response.cancelSample")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={applySampleData}
|
||||
disabled={!sampleDraft.trim() || Boolean(sampleError)}
|
||||
>
|
||||
{t("preview.response.applySample")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
{responseJson ? (
|
||||
<>
|
||||
<ScrollArea h={360} type="auto">
|
||||
<ResponseTree value={cachedJson} onInsertDataPath={onInsertDataPath} />
|
||||
</ScrollArea>
|
||||
{fetchResult?.rawResponse && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], { type: "application/json" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}}
|
||||
>
|
||||
{t("preview.rawResponse")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyPreview icon={<IconCode size={22} />} text={t("preview.response.empty")} />
|
||||
)}
|
||||
</Stack>
|
||||
<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">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact label={t("preview.request.method")} value={formValues.method} />
|
||||
<RequestFact label={t("preview.request.authentication")} value={formValues.authType} />
|
||||
<RequestFact label={t("preview.request.endpoint")} value={redactUrl(formValues.url)} />
|
||||
<RequestFact
|
||||
label={t("preview.request.status")}
|
||||
value={
|
||||
fetchResult?.responseInfo ? String(fetchResult.responseInfo.status) : t("preview.request.notRun")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{namedRequests.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.named", { count: namedRequests.length })}
|
||||
</Text>
|
||||
{namedRequests.map((request) => (
|
||||
<Paper key={request.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{request.id}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
|
||||
{request.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={methodColor(request.method)}>
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t(`preview.request.permission.${request.minimumBoardPermission ?? "view"}` as never)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{journalQuery.data && journalQuery.data.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.journal")}
|
||||
</Text>
|
||||
{journalQuery.data.map((entry) => (
|
||||
<Paper key={entry.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{entry.requestId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" truncate>
|
||||
{entry.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColor(entry.method)} variant="light">
|
||||
{entry.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color={journalEntryColor(entry)}>
|
||||
{entry.simulated ? t("preview.request.simulated") : (entry.status ?? "—")}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" w={56} ta="right">
|
||||
{t("preview.request.duration", { duration: entry.durationMs })}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.request.journalEmpty")}
|
||||
</Text>
|
||||
)}
|
||||
<Alert color="blue" variant="light" p="xs" icon={<IconClipboard size={15} />}>
|
||||
<Text size="xs">{t("preview.request.redacted")}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
<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">
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.templateSize")}
|
||||
value={t("preview.diagnostics.characters", {
|
||||
count: (formValues.displayConfig.template as string | undefined)?.length ?? 0,
|
||||
})}
|
||||
/>
|
||||
<RequestFact label={t("preview.diagnostics.namedRequests")} value={String(namedRequests.length)} />
|
||||
<RequestFact label={t("preview.diagnostics.methods")} value={httpMethods.join(", ") || "GET"} />
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.networkScope")}
|
||||
value={
|
||||
(formValues.displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{templateDiagnostics.length === 0 ? (
|
||||
<Alert color="green" variant="light" p="xs" icon={<IconCheck size={15} />}>
|
||||
<Text size="xs">{t("preview.diagnostics.ready")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
templateDiagnostics.map((diagnostic, index) => (
|
||||
<Alert
|
||||
key={`${diagnostic.code}-${index}`}
|
||||
color={{ error: "red", warning: "yellow" }[diagnostic.severity] ?? "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{editorT(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseTree({ value, onInsertDataPath }: { value: unknown; onInsertDataPath?: (path: string) => void }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const data = useMemo(() => [createResponseTreeNode(value, "data", "data")], [value]);
|
||||
const tree = useTree({ initialExpandedState: getTreeExpandedState(data, ["data"]) });
|
||||
return (
|
||||
<Tree
|
||||
data={data}
|
||||
tree={tree}
|
||||
levelOffset={14}
|
||||
expandOnClick
|
||||
selectOnClick={false}
|
||||
renderNode={(payload) => (
|
||||
<ResponseTreeNode
|
||||
{...payload}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
copyLabel={t("preview.response.copyPath")}
|
||||
copiedLabel={t("preview.response.pathCopied")}
|
||||
insertLabel={t("preview.response.insertPath")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponseTreeData extends TreeNodeData {
|
||||
path: string;
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
function collectionDisplayValue(value: unknown, count: number): string {
|
||||
if (Array.isArray(value)) return `[${count}]`;
|
||||
return `{${count}}`;
|
||||
}
|
||||
|
||||
function createResponseTreeNode(value: unknown, path: string, label: string): ResponseTreeData {
|
||||
const isCollection = value !== null && typeof value === "object";
|
||||
const entries = isCollection ? Object.entries(value as Record<string, unknown>) : [];
|
||||
return {
|
||||
value: path,
|
||||
label,
|
||||
path,
|
||||
displayValue: isCollection ? collectionDisplayValue(value, entries.length) : formatTreeValue(value),
|
||||
children: entries.map(([key, childValue]) =>
|
||||
createResponseTreeNode(childValue, appendDataPath(path, key, Array.isArray(value)), key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function ResponseTreeNode({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
onInsertDataPath,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
insertLabel,
|
||||
}: RenderTreeNodePayload & {
|
||||
onInsertDataPath?: (path: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
insertLabel: string;
|
||||
}) {
|
||||
const responseNode = node as ResponseTreeData;
|
||||
return (
|
||||
<Group {...elementProps} gap={4} wrap="nowrap" mih={28} className="response-tree-row">
|
||||
<div style={{ width: 16, flexShrink: 0 }}>
|
||||
{hasChildren && (expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />)}
|
||||
</div>
|
||||
<Code style={{ flex: "0 1 auto", overflow: "hidden", textOverflow: "ellipsis", fontSize: 11 }}>
|
||||
{responseNode.label}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||
{responseNode.displayValue}
|
||||
</Text>
|
||||
<Group gap={2} wrap="nowrap" className="response-tree-actions">
|
||||
<CopyButton value={responseNode.path}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={copied ? "green" : "gray"}
|
||||
size={24}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
{onInsertDataPath && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size={24}
|
||||
aria-label={insertLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onInsertDataPath(responseNode.path);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function appendDataPath(parent: string, key: string, isArray: boolean) {
|
||||
if (isArray) return `${parent}[${key}]`;
|
||||
return /^[A-Za-z_$][\w$]*$/u.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
function formatTreeValue(value: unknown) {
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (value === undefined) return "undefined";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function EmptyPreview({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<Center h={240}>
|
||||
<Stack align="center" gap="xs" c="dimmed">
|
||||
{icon}
|
||||
<Text size="xs" ta="center">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} lineClamp={2}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: unknown }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const typed = data as Record<string, unknown>;
|
||||
if (!typed || typeof typed !== "object") return null;
|
||||
|
||||
const dataType = typed.type as string | undefined;
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm" h="100%">
|
||||
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
|
||||
{(typed.buttonLabel as string) ?? t("preview.execute")}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) return <Component data={typed} />;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
}
|
||||
]`;
|
||||
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"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]);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"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,6 +3,7 @@ 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";
|
||||
@@ -28,128 +29,6 @@ function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string;
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildDisplayInitialValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: "",
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as Array<{ header: string; jsonPath: string }>) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (
|
||||
(config.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []
|
||||
).map((item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
})),
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({
|
||||
label: bar.label,
|
||||
valuePath: bar.valuePath,
|
||||
maxPath: bar.maxPath ?? "",
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
})),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
goodValues: item.goodValues.join(", "),
|
||||
}),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template: (config.template as string) ?? "",
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
const overrides = typeOverrides[displayType]?.() ?? {};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
interface EditCustomWidgetPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
@@ -164,7 +43,7 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
const displayConfig = definition.displayConfig as Record<string, unknown>;
|
||||
const displayValues = buildDisplayInitialValues(definition.displayType, displayConfig);
|
||||
const displayValues = buildDisplayFormValues(definition.displayType, displayConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, test } from "vitest";
|
||||
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { startMockApiServerAsync } from "./shared/mock-api-server";
|
||||
import { startMockApiContainerAsync } from "./shared/mock-api-container";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const adminCredentials = {
|
||||
@@ -18,7 +18,7 @@ const updatedTemplate = '<Stack gap="xs"><Text>{data.title}</Text><Badge color="
|
||||
|
||||
describe("Custom JSX custom widgets", () => {
|
||||
test("creates, previews, and edits a customJsx widget", async () => {
|
||||
const mockApi = await startMockApiServerAsync({
|
||||
const mockApi = await startMockApiContainerAsync({
|
||||
title: "E2E Widget",
|
||||
status: "online",
|
||||
value: 42,
|
||||
@@ -52,12 +52,13 @@ describe("Custom JSX custom widgets", () => {
|
||||
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX");
|
||||
await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`);
|
||||
await page.getByRole("combobox", { name: "Display Type" }).click();
|
||||
await page.getByRole("option", { name: "Custom JSX" }).click();
|
||||
await page.getByRole("button", { name: /Custom JSX/u }).click();
|
||||
await page.getByRole("combobox", { name: "Network scope" }).click();
|
||||
await page.getByRole("option", { name: "Private networks" }).click();
|
||||
await page.getByLabel("JSX Template").fill(initialTemplate);
|
||||
|
||||
await page.getByRole("button", { name: "Test" }).first().click();
|
||||
const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" });
|
||||
const previewPanel = page.getByRole("complementary", { name: "Preview" });
|
||||
await previewPanel.getByRole("button", { name: "Test" }).click();
|
||||
await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
@@ -65,18 +66,18 @@ describe("Custom JSX custom widgets", () => {
|
||||
await page.getByRole("button", { name: "Create" }).first().click();
|
||||
await expect(page.getByText('Widget "E2E Custom JSX" created successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate);
|
||||
await expect(page.getByLabel("JSX Template")).toHaveText(initialTemplate);
|
||||
|
||||
await page.getByLabel("JSX Template").fill(updatedTemplate);
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
|
||||
await expect(page.getByLabel("JSX Template")).toHaveText(updatedTemplate);
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).first().click();
|
||||
await expect(page.getByText('Widget "E2E Custom JSX" updated successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
|
||||
await expect(page.getByLabel("JSX Template")).toHaveText(updatedTemplate);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await mockApi.close();
|
||||
await mockApi.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
|
||||
export interface MockApiContainer {
|
||||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startMockApiContainerAsync(responseBody: unknown): Promise<MockApiContainer> {
|
||||
const body = JSON.stringify(responseBody);
|
||||
const script = [
|
||||
'const { createServer } = require("node:http");',
|
||||
`const body = ${JSON.stringify(body)};`,
|
||||
'createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(8080, "0.0.0.0");',
|
||||
].join("\n");
|
||||
const container: StartedTestContainer = await new GenericContainer("node:24-alpine")
|
||||
.withCommand(["node", "-e", script])
|
||||
.withExposedPorts(8080)
|
||||
.withWaitStrategy(Wait.forHttp("/status", 8080))
|
||||
.start();
|
||||
|
||||
return {
|
||||
url: `http://${container.getIpAddress("bridge")}:8080`,
|
||||
stop: async () => void (await container.stop()),
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import type { Server } from "node:http";
|
||||
|
||||
export interface MockApiServer {
|
||||
url: string;
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const startMockApiServerAsync = async (responseBody: unknown): Promise<MockApiServer> => {
|
||||
const body = JSON.stringify(responseBody);
|
||||
|
||||
const server: Server = await new Promise((resolve) => {
|
||||
const s = createServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(body);
|
||||
});
|
||||
s.listen(0, "0.0.0.0", () => resolve(s));
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to start mock API server");
|
||||
}
|
||||
|
||||
return {
|
||||
port: address.port,
|
||||
url: `http://host.docker.internal:${address.port}`,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
};
|
||||
+8
-6
@@ -6,6 +6,8 @@
|
||||
"build": "cross-env CI=true turbo build",
|
||||
"clean": "git clean -xdf node_modules",
|
||||
"clean:workspaces": "turbo clean",
|
||||
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
|
||||
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
|
||||
"cli": "pnpm with-env tsx packages/cli/index.ts",
|
||||
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
||||
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
|
||||
@@ -27,12 +29,15 @@
|
||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
"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 --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||
"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:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:",
|
||||
"@semantic-release/changelog": "catalog:",
|
||||
@@ -64,8 +69,5 @@
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.10.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
"packageManager": "pnpm@11.10.0"
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./open-api": "./src/open-api.ts",
|
||||
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
|
||||
"./mcp": "./src/mcp.ts"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -28,6 +27,7 @@
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/custom-widgets": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
@@ -49,7 +49,6 @@
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@trpc/tanstack-react-query": "catalog:",
|
||||
"jsonpath-plus": "catalog:",
|
||||
"lodash.clonedeep": "catalog:",
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
|
||||
@@ -1,221 +1,40 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { eq, or } from "@homarr/db";
|
||||
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 {
|
||||
customWidgetAuthTypes,
|
||||
customWidgetCreateSchema,
|
||||
customWidgetDisplayTypes,
|
||||
customWidgetImportSchema,
|
||||
customWidgetMethods,
|
||||
customWidgetSecretKinds,
|
||||
customWidgetUpdateSchema,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
} from "@homarr/validation/custom-widget";
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "./display-data";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
import { managementQueryProcedures } from "./management-queries";
|
||||
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 { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
createPreviewSession,
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "./preview-sessions";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { templateProcedures } from "./template-procedures";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
|
||||
try {
|
||||
await appendPreviewJournal(...args);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to append custom widget preview journal", {
|
||||
sessionId: args[0].id,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const previewRuntimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
||||
const previewSessionRequestSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
requestId: z.string().min(1).max(64),
|
||||
params: previewRuntimeParamsSchema.default({}),
|
||||
});
|
||||
|
||||
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
|
||||
displayConfig: (value) => stringifySuperJson(value),
|
||||
};
|
||||
|
||||
let importJsonSchemaCache: Record<string, unknown> | null = null;
|
||||
function getImportJsonSchema() {
|
||||
if (!importJsonSchemaCache) {
|
||||
importJsonSchemaCache = {
|
||||
...z.toJSONSchema(customWidgetImportSchema),
|
||||
title: "Homarr Custom Widget",
|
||||
description:
|
||||
"Schema for importing/exporting custom widget definitions in Homarr. " +
|
||||
"All jsonPath fields use JSONPath syntax (e.g. $.data.count, $.items[0].name). " +
|
||||
"The displayConfig must match the chosen displayType. " +
|
||||
"Secrets (API keys, passwords) are not included in exports and must be configured separately after import.",
|
||||
};
|
||||
}
|
||||
return importJsonSchemaCache;
|
||||
}
|
||||
|
||||
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
|
||||
|
||||
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
|
||||
displayConfig.template = template;
|
||||
const result = displayConfigSchema.safeParse(displayConfig);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: result.error.issues
|
||||
.map((issue) => `${issue.path.map(String).join(".") || "template"}: ${issue.message}`)
|
||||
.join("; "),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
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()),
|
||||
...metadataProcedures,
|
||||
|
||||
validate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate a complete custom-widget JSON draft without saving or making network requests. REQUIRED: widget (the homarr-custom-widget-v3 object). Returns structured issue paths and messages so an agent can correct the draft and validate again.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.query(({ input }) => {
|
||||
const result = customWidgetImportSchema.safeParse(input.widget);
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false as const,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: true as const,
|
||||
issues: [],
|
||||
widget: result.data,
|
||||
};
|
||||
}),
|
||||
|
||||
all: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
|
||||
return definitions.map((def) => ({
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
iconUrl: def.iconUrl,
|
||||
url: def.url,
|
||||
method: def.method,
|
||||
displayType: def.displayType,
|
||||
authType: def.authType,
|
||||
enabled: def.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.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(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", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
enabled: definition.enabled,
|
||||
displayConfig,
|
||||
secrets: definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
hasValue: true,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
available: protectedProcedure
|
||||
.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({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
...managementQueryProcedures,
|
||||
|
||||
create: adminProcedure
|
||||
.meta({
|
||||
@@ -357,582 +176,13 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
logger.info("Deleted custom widget definition", { id: input.id });
|
||||
}),
|
||||
|
||||
readTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
...templateProcedures,
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
...transferProcedures,
|
||||
|
||||
const config = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget readTemplate",
|
||||
);
|
||||
|
||||
if (config.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
const template = (config.template as string | undefined) ?? "";
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
writeTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
template: z.string().optional(),
|
||||
templateLines: z.array(z.string()).optional(),
|
||||
})
|
||||
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
|
||||
message: "Provide either template or templateLines",
|
||||
})
|
||||
.refine((data) => !(data.template !== undefined && data.templateLines !== undefined), {
|
||||
message: "Provide template or templateLines, not both",
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolvedTemplate =
|
||||
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
|
||||
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget writeTemplate",
|
||||
);
|
||||
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedTemplate.trim().length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
|
||||
}
|
||||
if (resolvedTemplate.length > 50_000) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
|
||||
}
|
||||
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
|
||||
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Updated custom widget template", { id: input.id });
|
||||
return {
|
||||
id: input.id,
|
||||
template: resolvedTemplate,
|
||||
templateLines: resolvedTemplate.split("\n"),
|
||||
revision: getTemplateRevision(resolvedTemplate),
|
||||
};
|
||||
}),
|
||||
|
||||
patchTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
expectedRevision: z.string().length(16),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
startLine: z.number().int().min(1),
|
||||
deleteCount: z.number().int().min(0),
|
||||
replacementLines: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
}),
|
||||
)
|
||||
.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", message: "Custom widget definition not found" });
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget patchTemplate",
|
||||
);
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
|
||||
}
|
||||
|
||||
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
|
||||
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Template changed since it was read. Read the template again and retry the patch.",
|
||||
});
|
||||
}
|
||||
|
||||
const lines = currentTemplate.split("\n");
|
||||
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
|
||||
let nextHigherStart = lines.length + 2;
|
||||
for (const edit of edits) {
|
||||
if (edit.startLine > lines.length + 1 || edit.startLine + edit.deleteCount - 1 > lines.length) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Edit at line ${edit.startLine} is out of range` });
|
||||
}
|
||||
if (edit.startLine + Math.max(edit.deleteCount, 1) > nextHigherStart) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template patch edits must not overlap" });
|
||||
}
|
||||
nextHigherStart = edit.startLine;
|
||||
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
|
||||
}
|
||||
|
||||
const template = lines.join("\n");
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
|
||||
return {
|
||||
id: input.id,
|
||||
template,
|
||||
templateLines: lines,
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
export: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Export one custom widget as a homarr-custom-widget-v3 object suitable for validation, modification, and re-import. REQUIRED: id. Secrets are excluded.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
return {
|
||||
$schema: "homarr-custom-widget-v3" as const,
|
||||
name: definition.name,
|
||||
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: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig during export",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
import: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a custom widget directly from a complete homarr-custom-widget-v3 import object. Admin only. Use customWidget_validate first. This is the preferred creation tool for AI-generated authoring bundles because secrets are omitted and configured separately.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetImportSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
logger.info("Imported custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
preview: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(customWidgetMethods),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().optional(),
|
||||
requestBody: z.string().optional(),
|
||||
displayType: z.enum(customWidgetDisplayTypes),
|
||||
displayConfig: displayConfigSchema,
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
|
||||
definitionId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secrets = [...input.secrets];
|
||||
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (existing) {
|
||||
for (const dbSecret of existing.secrets) {
|
||||
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
|
||||
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
|
||||
const previewSession = v2Config.success
|
||||
? await createPreviewSession({
|
||||
userId: ctx.session.user.id,
|
||||
baseUrl: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
secrets,
|
||||
networkScope: v2Config.data.networkScope,
|
||||
requests: v2Config.data.requests,
|
||||
definitionId: input.definitionId,
|
||||
})
|
||||
: null;
|
||||
const storedPreviewSession = previewSession
|
||||
? await getPreviewSession(previewSession.id, ctx.session.user.id)
|
||||
: null;
|
||||
|
||||
if (input.method !== "GET") {
|
||||
return {
|
||||
success: true as const,
|
||||
simulated: true as const,
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${ctx.session.user.id}`,
|
||||
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: input.url,
|
||||
method: input.method,
|
||||
body: undefined,
|
||||
auth: { type: input.authType, secrets, headerName: input.headerName },
|
||||
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
|
||||
kind: "query",
|
||||
});
|
||||
if (storedPreviewSession) {
|
||||
await recordPreviewJournal(storedPreviewSession, {
|
||||
requestId: "base",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: new URL(input.url).pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
const responseInfo = { status: response.status, statusText: response.statusText };
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
displayData,
|
||||
previewSession,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget preview failed", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to fetch data",
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
previewSession,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
|
||||
previewQuery: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
|
||||
setPreviewLiveActions: adminProcedure
|
||||
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
|
||||
),
|
||||
|
||||
previewJournal: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ sessionId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
|
||||
|
||||
simulatePreviewAction: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
renderRequestTarget(session.baseUrl, request, input.params);
|
||||
renderRequestBody(request.bodyTemplate, input.params);
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
simulated: true as const,
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
requiredPermission: request.minimumBoardPermission,
|
||||
};
|
||||
}),
|
||||
|
||||
previewAction: adminProcedure
|
||||
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
if (!session.liveActions) {
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
|
||||
}
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: request.method === "DELETE" ? "delete" : "action",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "action",
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
simulated: false as const,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
...previewBaseProcedures,
|
||||
...previewQueryProcedures,
|
||||
...previewActionProcedures,
|
||||
|
||||
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { JSONPath } from "jsonpath-plus";
|
||||
|
||||
type Config = Record<string, unknown>;
|
||||
type Extractor = (json: unknown, config: Config) => unknown;
|
||||
|
||||
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false });
|
||||
|
||||
const queryRows = (json: unknown, path: string): unknown[] => {
|
||||
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
|
||||
const first = rows[0];
|
||||
if (Array.isArray(first)) return first as unknown[];
|
||||
return rows;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback: number): number => {
|
||||
const num = Number(value);
|
||||
if (num) return num;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const optionalMax = (max: unknown): number | undefined => {
|
||||
if (max === undefined) return undefined;
|
||||
return toNumber(max, 100);
|
||||
};
|
||||
|
||||
const isGoodValue = (value: unknown, goodValues: string[]): boolean =>
|
||||
goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
|
||||
|
||||
const extractors: Record<string, Extractor> = {
|
||||
singleValue: (json, c) => ({
|
||||
type: "singleValue",
|
||||
label: (c.label as string) ?? "",
|
||||
unit: (c.unit as string) ?? "",
|
||||
value: query(json, (c.jsonPath as string) ?? "$"),
|
||||
valueSize: c.valueSize ?? "lg",
|
||||
labelPosition: c.labelPosition ?? "below",
|
||||
}),
|
||||
keyValue: (json, c) => ({
|
||||
type: "keyValue",
|
||||
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
|
||||
label: m.label,
|
||||
unit: m.unit,
|
||||
value: query(json, m.jsonPath),
|
||||
})),
|
||||
layout: c.layout ?? "list",
|
||||
columns: c.columns ?? 2,
|
||||
}),
|
||||
table: (json, c) => {
|
||||
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
|
||||
const flatRows = queryRows(json, (c.tablePath as string) ?? "$");
|
||||
return {
|
||||
type: "table",
|
||||
columns: columns.map((col) => col.header),
|
||||
rows: flatRows.map((row) => columns.map((col) => query(row, col.jsonPath))),
|
||||
striped: c.striped ?? true,
|
||||
compact: c.compact ?? false,
|
||||
};
|
||||
},
|
||||
statGrid: (json, c) => ({
|
||||
type: "statGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
value: query(json, item.jsonPath),
|
||||
}),
|
||||
),
|
||||
columns: c.columns ?? 2,
|
||||
cardStyle: c.cardStyle ?? "filled",
|
||||
}),
|
||||
progressBars: (json, c) => ({
|
||||
type: "progressBars",
|
||||
bars: (
|
||||
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
|
||||
).map((bar) => {
|
||||
const value = query(json, bar.valuePath);
|
||||
const max = bar.maxPath ? query(json, bar.maxPath) : undefined;
|
||||
return {
|
||||
label: bar.label,
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
value: toNumber(value, 0),
|
||||
max: optionalMax(max),
|
||||
};
|
||||
}),
|
||||
showPercentage: c.showPercentage ?? true,
|
||||
barSize: c.barSize ?? "md",
|
||||
}),
|
||||
statusIndicator: (json, c) => ({
|
||||
type: "statusIndicator",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
|
||||
const value = query(json, item.jsonPath);
|
||||
return { label: item.label, value: String(value ?? "unknown"), isGood: isGoodValue(value, item.goodValues) };
|
||||
}),
|
||||
layout: c.layout ?? "list",
|
||||
dotSize: c.dotSize ?? "md",
|
||||
}),
|
||||
countGrid: (json, c) => ({
|
||||
type: "countGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
value: query(json, item.jsonPath),
|
||||
})),
|
||||
columns: c.columns ?? 2,
|
||||
valueSize: c.valueSize ?? "md",
|
||||
}),
|
||||
raw: (json, c) => ({
|
||||
type: "raw",
|
||||
data: query(json, (c.jsonPath as string) ?? "$"),
|
||||
maxHeight: c.maxHeight ?? 300,
|
||||
}),
|
||||
actionButton: (_json, c) => ({
|
||||
type: "actionButton",
|
||||
buttonLabel: c.buttonLabel ?? "Execute",
|
||||
buttonColor: c.buttonColor ?? "blue",
|
||||
confirmText: c.confirmText ?? "",
|
||||
successMessage: c.successMessage ?? "",
|
||||
}),
|
||||
customJsx: (json, config) => ({
|
||||
type: "customJsx" as const,
|
||||
template: config.template as string,
|
||||
data: json,
|
||||
jsxApiVersion: config.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestCapabilities:
|
||||
config.jsxApiVersion === 2 && Array.isArray(config.requests)
|
||||
? (config.requests as Array<Record<string, unknown>>).map((request) => ({
|
||||
id: request.id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
minimumBoardPermission: request.minimumBoardPermission,
|
||||
}))
|
||||
: [],
|
||||
}),
|
||||
};
|
||||
|
||||
export function extractDisplayData(json: unknown, displayType: string, displayConfig: Config): unknown {
|
||||
const type = (displayConfig.type as string) ?? displayType;
|
||||
const extractor = extractors[type];
|
||||
if (!extractor) {
|
||||
throw new Error(`Unknown display type: ${type}`);
|
||||
}
|
||||
return extractor(json, displayConfig);
|
||||
}
|
||||
|
||||
export function extractDisplayDataWithFallback(json: unknown, displayType: string, displayConfig: Config): unknown {
|
||||
const type = (displayConfig.type as string) ?? displayType;
|
||||
const extractor = extractors[type] ?? extractors.singleValue;
|
||||
return extractor?.(json, displayConfig);
|
||||
}
|
||||
|
||||
export function extractActionButtonDisplay(displayConfig: Config): unknown {
|
||||
return extractors.actionButton?.(null, displayConfig);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { CustomWidgetDomainError } from "@homarr/custom-widgets/server";
|
||||
|
||||
class RequestLimitTransportError extends FlattenError {}
|
||||
|
||||
export function toTrpcError(error: unknown): never {
|
||||
if (error instanceof CustomWidgetDomainError) {
|
||||
const cause = error.retryAfterMs
|
||||
? new RequestLimitTransportError(error.message, { retryAfterMs: error.retryAfterMs })
|
||||
: error;
|
||||
throw new TRPCError({ code: error.code, message: error.message, cause });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
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";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
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.",
|
||||
},
|
||||
})
|
||||
.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,
|
||||
}));
|
||||
}),
|
||||
|
||||
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.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(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", message: "Custom widget definition not found" });
|
||||
return {
|
||||
...definition,
|
||||
displayConfig: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
),
|
||||
secrets: definition.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
hasValue: true,
|
||||
updatedAt: secret.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
available: protectedProcedure
|
||||
.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({
|
||||
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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetImportSchema } 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;
|
||||
}
|
||||
|
||||
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()),
|
||||
|
||||
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.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.query(({ input }) => {
|
||||
const result = customWidgetImportSchema.safeParse(input.widget);
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false as const,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { valid: true as const, issues: [], widget: result.data };
|
||||
}),
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
|
||||
import type { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { displayConfigSchema } from "@homarr/custom-widgets/core";
|
||||
import type { DisplayConfig } from "@homarr/custom-widgets/core";
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): Record<string, unknown> {
|
||||
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): DisplayConfig {
|
||||
try {
|
||||
return superjson.parse(raw) as Record<string, unknown>;
|
||||
return displayConfigSchema.parse(parseSuperJson(raw));
|
||||
} catch {
|
||||
logger.error(context, { id });
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
export const previewActionProcedures = {
|
||||
simulatePreviewAction: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
renderRequestTarget(session.baseUrl, request, input.params);
|
||||
renderRequestBody(request.bodyTemplate, input.params);
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
simulated: true as const,
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
requiredPermission: request.minimumBoardPermission,
|
||||
};
|
||||
}),
|
||||
previewAction: adminProcedure
|
||||
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
if (!session.liveActions) {
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
|
||||
}
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: request.method === "DELETE" ? "delete" : "action",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "action",
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
simulated: false as const,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetAuthTypes,
|
||||
customWidgetDisplayTypes,
|
||||
customWidgetMethods,
|
||||
customWidgetSecretKinds,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
extractActionButtonDisplay,
|
||||
extractDisplayDataWithFallback,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const previewBaseProcedures = {
|
||||
preview: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(customWidgetMethods),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().optional(),
|
||||
requestBody: z.string().optional(),
|
||||
displayType: z.enum(customWidgetDisplayTypes),
|
||||
displayConfig: displayConfigSchema,
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
|
||||
definitionId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secrets = [...input.secrets];
|
||||
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (existing) {
|
||||
for (const dbSecret of existing.secrets) {
|
||||
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
|
||||
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
|
||||
const previewSession = v2Config.success
|
||||
? await createPreviewSession({
|
||||
userId: ctx.session.user.id,
|
||||
baseUrl: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
secrets,
|
||||
networkScope: v2Config.data.networkScope,
|
||||
requests: v2Config.data.requests,
|
||||
definitionId: input.definitionId,
|
||||
})
|
||||
: null;
|
||||
const storedPreviewSession = previewSession
|
||||
? await getPreviewSession(previewSession.id, ctx.session.user.id)
|
||||
: null;
|
||||
|
||||
if (input.method !== "GET") {
|
||||
return {
|
||||
success: true as const,
|
||||
simulated: true as const,
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${ctx.session.user.id}`,
|
||||
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: input.url,
|
||||
method: input.method,
|
||||
body: undefined,
|
||||
auth: { type: input.authType, secrets, headerName: input.headerName },
|
||||
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
|
||||
kind: "query",
|
||||
});
|
||||
if (storedPreviewSession) {
|
||||
await recordPreviewJournal(storedPreviewSession, {
|
||||
requestId: "base",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: new URL(input.url).pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
const responseInfo = { status: response.status, statusText: response.statusText };
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
displayData,
|
||||
previewSession,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget preview failed", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to fetch data",
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
previewSession,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { appendPreviewJournal } from "./preview-sessions";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget-preview" });
|
||||
|
||||
export const previewSessionRequestSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
requestId: z.string().min(1).max(64),
|
||||
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
|
||||
});
|
||||
|
||||
export const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
|
||||
try {
|
||||
await appendPreviewJournal(...args);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to append custom widget preview journal", {
|
||||
sessionId: args[0].id,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import {
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
export const previewQueryProcedures = {
|
||||
previewQuery: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
|
||||
setPreviewLiveActions: adminProcedure
|
||||
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
|
||||
),
|
||||
|
||||
previewJournal: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ sessionId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
|
||||
};
|
||||
@@ -1,175 +1,79 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import {
|
||||
customJsxNetworkScopes,
|
||||
customJsxRequestSchema,
|
||||
customWidgetAuthTypes,
|
||||
customWidgetSecretKinds,
|
||||
} from "@homarr/validation/custom-widget";
|
||||
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "@homarr/validation/custom-widget";
|
||||
import { CustomWidgetPreviewSessionService } from "@homarr/custom-widgets/server";
|
||||
import type {
|
||||
CreatePreviewSessionInput,
|
||||
CustomWidgetPreviewJournalEntry,
|
||||
CustomWidgetPreviewSession,
|
||||
PreviewSessionStore,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
|
||||
const PREVIEW_SESSION_TTL_MS = 5 * 60_000;
|
||||
const PREVIEW_SESSION_PREFIX = "custom-widget:preview-session:";
|
||||
const PREVIEW_JOURNAL_PREFIX = "custom-widget:preview-journal:";
|
||||
const MAX_PREVIEW_JOURNAL_ENTRIES = 50;
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
const storedPreviewSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.number(),
|
||||
baseUrl: z.string(),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().nullable(),
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
|
||||
networkScope: z.enum(customJsxNetworkScopes),
|
||||
requests: z.array(customJsxRequestSchema),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
const SESSION_PREFIX = "custom-widget:preview-session:";
|
||||
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
|
||||
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof storedPreviewSessionSchema>;
|
||||
class RedisPreviewSessionStore implements PreviewSessionStore {
|
||||
public constructor(private readonly redis: RedisClient) {}
|
||||
|
||||
const previewJournalEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
requestId: z.string(),
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
pathTemplate: z.string(),
|
||||
status: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
simulated: z.boolean(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type CustomWidgetPreviewJournalEntry = z.infer<typeof previewJournalEntrySchema>;
|
||||
public async saveSession(id: string, value: unknown, ttlMs: number) {
|
||||
await this.redis.set(`${SESSION_PREFIX}${id}`, JSON.stringify(value), "PX", ttlMs);
|
||||
}
|
||||
|
||||
export interface CreatePreviewSessionInput {
|
||||
userId: string;
|
||||
baseUrl: string;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
secrets: Array<{ kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
requests: CustomJsxRequest[];
|
||||
definitionId?: string;
|
||||
public getSession(id: string) {
|
||||
return this.redis.get(`${SESSION_PREFIX}${id}`);
|
||||
}
|
||||
public async deleteSession(id: string) {
|
||||
await this.redis.del(`${SESSION_PREFIX}${id}`);
|
||||
}
|
||||
|
||||
public async appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number) {
|
||||
const key = `${JOURNAL_PREFIX}${id}`;
|
||||
await this.redis
|
||||
.multi()
|
||||
.lpush(key, JSON.stringify(value))
|
||||
.ltrim(key, 0, maxEntries - 1)
|
||||
.pexpire(key, ttlMs)
|
||||
.exec();
|
||||
}
|
||||
|
||||
public getJournal(id: string, maxEntries: number) {
|
||||
return this.redis.lrange(`${JOURNAL_PREFIX}${id}`, 0, maxEntries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const localSessions = new Map<string, CustomWidgetPreviewSession>();
|
||||
const localJournals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
|
||||
let service: CustomWidgetPreviewSessionService | undefined;
|
||||
function getService() {
|
||||
if (service) return service;
|
||||
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
|
||||
service = new CustomWidgetPreviewSessionService({
|
||||
createId,
|
||||
encrypt: encryptSecret,
|
||||
decrypt: (value) => decryptSecret(value as `${string}.${string}`),
|
||||
store: useLocal ? undefined : new RedisPreviewSessionStore(createRedisClient()),
|
||||
});
|
||||
return service;
|
||||
}
|
||||
|
||||
const getRedisClient = () => {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
|
||||
redisClient ??= createRedisClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
const sessionKey = (id: string) => `${PREVIEW_SESSION_PREFIX}${id}`;
|
||||
const journalKey = (id: string) => `${PREVIEW_JOURNAL_PREFIX}${id}`;
|
||||
|
||||
const saveSession = async (session: CustomWidgetPreviewSession) => {
|
||||
const ttlMs = Math.max(0, session.expiresAt - Date.now());
|
||||
if (ttlMs === 0) throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
localSessions.set(session.id, session);
|
||||
return;
|
||||
async function call<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
await redis.set(sessionKey(session.id), JSON.stringify(session), "PX", ttlMs);
|
||||
};
|
||||
}
|
||||
|
||||
export const createPreviewSession = async (input: CreatePreviewSessionInput) => {
|
||||
const session: CustomWidgetPreviewSession = {
|
||||
id: createId(),
|
||||
userId: input.userId,
|
||||
expiresAt: Date.now() + PREVIEW_SESSION_TTL_MS,
|
||||
baseUrl: input.baseUrl,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName ?? null,
|
||||
secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: encryptSecret(secret.value) })),
|
||||
networkScope: input.networkScope,
|
||||
requests: input.requests,
|
||||
definitionId: input.definitionId,
|
||||
liveActions: false,
|
||||
};
|
||||
await saveSession(session);
|
||||
return { id: session.id, expiresAt: session.expiresAt, liveActions: false as const };
|
||||
};
|
||||
export type { CreatePreviewSessionInput, CustomWidgetPreviewJournalEntry, CustomWidgetPreviewSession };
|
||||
|
||||
export const getPreviewSession = async (id: string, userId: string): Promise<CustomWidgetPreviewSession> => {
|
||||
const redis = getRedisClient();
|
||||
const raw = redis ? await redis.get(sessionKey(id)) : localSessions.get(id);
|
||||
let candidate: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
candidate = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
candidate = null;
|
||||
}
|
||||
}
|
||||
const parsed = storedPreviewSessionSchema.safeParse(candidate);
|
||||
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= Date.now()) {
|
||||
if (!redis) localSessions.delete(id);
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export const setPreviewSessionLiveActions = async (id: string, userId: string, enabled: boolean) => {
|
||||
const session = await getPreviewSession(id, userId);
|
||||
const updated = { ...session, liveActions: enabled };
|
||||
await saveSession(updated);
|
||||
return { id, expiresAt: updated.expiresAt, liveActions: enabled };
|
||||
};
|
||||
|
||||
export const appendPreviewJournal = async (
|
||||
export const createPreviewSession = (input: CreatePreviewSessionInput) => call(() => getService().create(input));
|
||||
export const getPreviewSession = (id: string, userId: string) => call(() => getService().get(id, userId));
|
||||
export const setPreviewSessionLiveActions = (id: string, userId: string, enabled: boolean) =>
|
||||
call(() => getService().setLiveActions(id, userId, enabled));
|
||||
export const appendPreviewJournal = (
|
||||
session: CustomWidgetPreviewSession,
|
||||
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
||||
) => {
|
||||
const value = previewJournalEntrySchema.parse({ ...entry, id: createId(), timestamp: Date.now() });
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
localJournals.set(
|
||||
session.id,
|
||||
[value, ...(localJournals.get(session.id) ?? [])].slice(0, MAX_PREVIEW_JOURNAL_ENTRIES),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ttlMs = Math.max(1, session.expiresAt - Date.now());
|
||||
await redis
|
||||
.multi()
|
||||
.lpush(journalKey(session.id), JSON.stringify(value))
|
||||
.ltrim(journalKey(session.id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
|
||||
.pexpire(journalKey(session.id), ttlMs)
|
||||
.exec();
|
||||
};
|
||||
|
||||
export const getPreviewJournal = async (id: string, userId: string) => {
|
||||
await getPreviewSession(id, userId);
|
||||
const redis = getRedisClient();
|
||||
const entries = redis
|
||||
? await redis.lrange(journalKey(id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
|
||||
: (localJournals.get(id) ?? []);
|
||||
return entries.flatMap((entry) => {
|
||||
let candidate: unknown = entry;
|
||||
if (typeof entry === "string") {
|
||||
try {
|
||||
candidate = JSON.parse(entry) as unknown;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const parsed = previewJournalEntrySchema.safeParse(candidate);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
});
|
||||
};
|
||||
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) =>
|
||||
session.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: decryptSecret(secret.value as `${string}.${string}`),
|
||||
}));
|
||||
) => call(() => getService().appendJournal(session, entry));
|
||||
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) => getService().getSecrets(session);
|
||||
@@ -1,429 +1,83 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Agent, fetch, Headers } from "undici";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import type { Response } from "undici";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "@homarr/validation/custom-widget";
|
||||
import {
|
||||
assertJsonBudget as assertDomainJsonBudget,
|
||||
assertSafeStaticHeaders as assertDomainSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest as executeDomainRequest,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
resolveAndValidateHost as resolveDomainHost,
|
||||
resolveSameOriginTarget as resolveDomainTarget,
|
||||
validateCustomWidgetUrl as validateDomainUrl,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server";
|
||||
|
||||
import { applyAuth } from "./auth";
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
export type {
|
||||
CustomWidgetAuthConfig,
|
||||
CustomWidgetHttpRequest,
|
||||
CustomWidgetHttpResponse,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
export {
|
||||
classifyAddress,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
};
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:http" });
|
||||
|
||||
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
||||
export const MAX_RESPONSE_JSON_DEPTH = 32;
|
||||
export const MAX_RESPONSE_JSON_NODES = 50_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
|
||||
const RESERVED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
]);
|
||||
|
||||
const alwaysBlockedAddresses = new BlockList();
|
||||
alwaysBlockedAddresses.addSubnet("0.0.0.0", 8, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("100.64.0.0", 10, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.0.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.2.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.18.0.0", 15, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.51.100.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("203.0.113.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("224.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("240.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addAddress("::", "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("64:ff9b::", 96, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("100::", 64, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("2001:db8::", 32, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("fe80::", 10, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("ff00::", 8, "ipv6");
|
||||
alwaysBlockedAddresses.addAddress("fd00:ec2::254", "ipv6");
|
||||
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
|
||||
const loopbackAddresses = new BlockList();
|
||||
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
loopbackAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
type AddressFamily = 4 | 6;
|
||||
type ResolvedAddress = { address: string; family: AddressFamily };
|
||||
type AddressClass = "public" | "private" | "loopback" | "blocked";
|
||||
|
||||
export interface CustomWidgetAuthConfig {
|
||||
type: string;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
headerName?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
baseUrl: string;
|
||||
targetUrl?: string | URL;
|
||||
method: CustomWidgetMethod;
|
||||
body?: string;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const responseCache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||
const inFlightRequests = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
|
||||
|
||||
const pruneResponseCache = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of responseCache) {
|
||||
if (entry.expiresAt <= now) responseCache.delete(key);
|
||||
}
|
||||
while (responseCache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
|
||||
const oldestKey = responseCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
responseCache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHostname = (hostname: string) =>
|
||||
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
|
||||
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
|
||||
|
||||
export const classifyAddress = (address: string): AddressClass => {
|
||||
const normalized = normalizeHostname(address).toLowerCase();
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
if (family === 0) return "blocked";
|
||||
|
||||
if (normalized.includes("::ffff:")) return "blocked";
|
||||
if (alwaysBlockedAddresses.check(normalized, familyName(family))) return "blocked";
|
||||
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
|
||||
if (privateAddresses.check(normalized, familyName(family))) return "private";
|
||||
return "public";
|
||||
};
|
||||
|
||||
const isAddressAllowed = (classification: AddressClass, scope: CustomJsxNetworkScope) => {
|
||||
if (classification === "blocked") return false;
|
||||
if (classification === "public") return true;
|
||||
if (classification === "private") return scope === "private" || scope === "loopback";
|
||||
return scope === "loopback";
|
||||
};
|
||||
|
||||
export const resolveAndValidateHost = async (
|
||||
hostname: string,
|
||||
scope: CustomJsxNetworkScope,
|
||||
): Promise<ResolvedAddress[]> => {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const literalFamily = isIP(normalized) as AddressFamily | 0;
|
||||
const addresses = literalFamily
|
||||
? [{ address: normalized, family: literalFamily }]
|
||||
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
|
||||
}
|
||||
|
||||
for (const resolved of addresses) {
|
||||
const classification = classifyAddress(resolved.address);
|
||||
if (!isAddressAllowed(classification, scope)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Target address is not allowed by the ${scope} network scope`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
};
|
||||
|
||||
export const validateCustomWidgetUrl = (value: string | URL): URL => {
|
||||
let url: URL;
|
||||
export function assertJsonBudget(value: unknown): void {
|
||||
try {
|
||||
url = value instanceof URL ? new URL(value) : new URL(value);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
|
||||
}
|
||||
if (url.hash) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const resolveSameOriginTarget = (baseUrlValue: string, targetUrlValue?: string | URL): URL => {
|
||||
const baseUrl = validateCustomWidgetUrl(baseUrlValue);
|
||||
const targetUrl = validateCustomWidgetUrl(targetUrlValue ?? baseUrl);
|
||||
if (targetUrl.origin !== baseUrl.origin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Named requests must stay on the widget's origin" });
|
||||
}
|
||||
return targetUrl;
|
||||
};
|
||||
|
||||
export const assertSafeStaticHeaders = (headers: Record<string, string> | undefined): void => {
|
||||
for (const name of Object.keys(headers ?? {})) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (
|
||||
RESERVED_HEADERS.has(normalized) ||
|
||||
normalized.startsWith("proxy-") ||
|
||||
normalized.startsWith("sec-") ||
|
||||
normalized.startsWith("x-forwarded-")
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createPinnedAgent = (addresses: ResolvedAddress[]) => {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = requestedFamily ? addresses.filter((address) => address.family === requestedFamily) : addresses;
|
||||
const selected = candidates[0];
|
||||
if (!selected) {
|
||||
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
return;
|
||||
}
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
|
||||
return new Agent({
|
||||
connect: { lookup: customLookup },
|
||||
connectTimeout: REQUEST_TIMEOUT_MS,
|
||||
headersTimeout: REQUEST_TIMEOUT_MS,
|
||||
bodyTimeout: REQUEST_TIMEOUT_MS,
|
||||
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
||||
});
|
||||
};
|
||||
|
||||
const readLimitedBody = async (response: Response): Promise<string> => {
|
||||
const contentLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
totalBytes += chunk.value.byteLength;
|
||||
if (totalBytes > MAX_RESPONSE_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
};
|
||||
|
||||
export const assertJsonBudget = (value: unknown): void => {
|
||||
let nodes = 0;
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
nodes += 1;
|
||||
if (nodes > MAX_RESPONSE_JSON_NODES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
|
||||
}
|
||||
if (depth > MAX_RESPONSE_JSON_DEPTH) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (const entry of current) visit(entry, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (current !== null && typeof current === "object") {
|
||||
for (const entry of Object.values(current)) visit(entry, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, 0);
|
||||
};
|
||||
|
||||
const parseResponseBody = async (response: Response): Promise<unknown> => {
|
||||
const text = await readLimitedBody(response);
|
||||
if (text === "") return null;
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.includes("json")) {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text) as unknown;
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
assertDomainJsonBudget(value);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
return text;
|
||||
toTrpcError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const performRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
if (input.kind === "query" && input.method !== "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Queries must use GET" });
|
||||
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
|
||||
try {
|
||||
assertDomainSafeStaticHeaders(headers);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
if (input.kind === "action" && input.method === "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
|
||||
}
|
||||
|
||||
export function validateCustomWidgetUrl(value: string | URL): URL {
|
||||
try {
|
||||
return validateDomainUrl(value);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
|
||||
}
|
||||
|
||||
export function resolveSameOriginTarget(baseUrl: string, target?: string | URL): URL {
|
||||
try {
|
||||
return resolveDomainTarget(baseUrl, target);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
assertSafeStaticHeaders(input.staticHeaders);
|
||||
}
|
||||
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
const initialOrigin = baseUrl.origin;
|
||||
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
||||
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
const addresses = await resolveAndValidateHost(currentUrl.hostname, input.networkScope);
|
||||
const dispatcher = createPinnedAgent(addresses);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
|
||||
if (input.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (input.auth) {
|
||||
if (input.auth.type === "apiKeyHeader") {
|
||||
assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||
}
|
||||
applyAuth(headers, currentUrl, input.auth.type, input.auth.secrets, input.auth.headerName);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(currentUrl, {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method === "GET" ? undefined : input.body,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget request failed", {
|
||||
origin: currentUrl.origin,
|
||||
method: input.method,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: error instanceof Error ? error.message : "External request failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const isRedirect = [301, 302, 303, 307, 308].includes(response.status);
|
||||
if (!isRedirect) {
|
||||
try {
|
||||
const data = await parseResponseBody(response);
|
||||
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
await response.body?.cancel();
|
||||
await dispatcher.close();
|
||||
if (redirectCount >= maxRedirects) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
|
||||
}
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
|
||||
}
|
||||
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
|
||||
if (redirected.origin !== initialOrigin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
|
||||
}
|
||||
currentUrl = redirected;
|
||||
export async function resolveAndValidateHost(hostname: string, scope: CustomWidgetHttpRequest["networkScope"]) {
|
||||
try {
|
||||
return await resolveDomainHost(hostname, scope);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const executeCustomWidgetRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
const cacheKey = input.method === "GET" ? input.cacheKey : undefined;
|
||||
if (!cacheKey) return performRequest(input);
|
||||
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.response;
|
||||
if (cached) responseCache.delete(cacheKey);
|
||||
|
||||
const inFlight = inFlightRequests.get(cacheKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = performRequest(input)
|
||||
.then((response) => {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
|
||||
pruneResponseCache();
|
||||
responseCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000,
|
||||
response,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.finally(() => inFlightRequests.delete(cacheKey));
|
||||
inFlightRequests.set(cacheKey, promise);
|
||||
return promise;
|
||||
};
|
||||
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
try {
|
||||
return await executeDomainRequest({
|
||||
...input,
|
||||
logError: (event) => logger.error("Custom widget request failed", event),
|
||||
});
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import { CustomWidgetRequestLimiter } from "@homarr/custom-widgets/server";
|
||||
import type { RequestLimitInput, RequestLimitStore } from "@homarr/custom-widgets/server";
|
||||
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:limits" });
|
||||
const WINDOW_MS = 60_000;
|
||||
const CONCURRENCY_TTL_MS = 30_000;
|
||||
|
||||
const categoryLimits = {
|
||||
query: 60,
|
||||
action: 10,
|
||||
delete: 3,
|
||||
} as const;
|
||||
|
||||
type RequestCategory = keyof typeof categoryLimits;
|
||||
|
||||
export interface RequestLimitInput {
|
||||
category: RequestCategory;
|
||||
userId?: string;
|
||||
itemId: string;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
class RequestLimitError extends FlattenError {
|
||||
constructor(message: string, retryAfterMs: number) {
|
||||
super(message, { retryAfterMs });
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const getRedisClient = () => {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
|
||||
redisClient ??= createRedisClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
|
||||
|
||||
const rateScript = `
|
||||
const RATE_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
return { count, redis.call('PTTL', KEYS[1]) }
|
||||
`;
|
||||
|
||||
const concurrencyScript = `
|
||||
const ACQUIRE_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
||||
if count > tonumber(ARGV[1]) then
|
||||
redis.call('DECR', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
if count > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); return 0 end
|
||||
return count
|
||||
`;
|
||||
|
||||
const releaseConcurrencyScript = `
|
||||
const RELEASE_SCRIPT = `
|
||||
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if count <= 1 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
if count <= 1 then redis.call('DEL', KEYS[1]); return 0 end
|
||||
return redis.call('DECR', KEYS[1])
|
||||
`;
|
||||
|
||||
const localRateBuckets = new Map<string, number[]>();
|
||||
const localConcurrency = new Map<string, number>();
|
||||
class RedisRequestLimitStore implements RequestLimitStore {
|
||||
public constructor(private readonly redis: RedisClient) {}
|
||||
|
||||
const throwLimitExceeded = (retryAfterMs: number): never => {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Custom widget request limit exceeded",
|
||||
cause: new RequestLimitError("Custom widget request limit exceeded", Math.max(1, retryAfterMs)),
|
||||
});
|
||||
};
|
||||
|
||||
const enforceLocalRateLimit = (key: string, limit: number) => {
|
||||
const now = Date.now();
|
||||
const recent = (localRateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
|
||||
if (recent.length >= limit) {
|
||||
throwLimitExceeded(WINDOW_MS - (now - (recent[0] ?? now)));
|
||||
}
|
||||
recent.push(now);
|
||||
localRateBuckets.set(key, recent);
|
||||
};
|
||||
|
||||
const acquireLocalConcurrency = (key: string, limit: number) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current >= limit) throwLimitExceeded(1_000);
|
||||
localConcurrency.set(key, current + 1);
|
||||
};
|
||||
|
||||
const releaseLocalConcurrency = (key: string) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current <= 1) localConcurrency.delete(key);
|
||||
else localConcurrency.set(key, current - 1);
|
||||
};
|
||||
|
||||
const enforceRedisRateLimit = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const result = (await redis.eval(rateScript, 1, key, WINDOW_MS)) as [number, number];
|
||||
const [count, ttl] = result.map(Number) as [number, number];
|
||||
if (count > limit) throwLimitExceeded(ttl > 0 ? ttl : WINDOW_MS);
|
||||
};
|
||||
|
||||
const acquireRedisConcurrency = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const count = Number(await redis.eval(concurrencyScript, 1, key, limit, CONCURRENCY_TTL_MS));
|
||||
if (count === 0) throwLimitExceeded(1_000);
|
||||
};
|
||||
|
||||
const releaseRedisConcurrency = async (redis: RedisClient, key: string) => {
|
||||
await redis.eval(releaseConcurrencyScript, 1, key);
|
||||
};
|
||||
|
||||
export const acquireCustomWidgetRequestLimit = async (input: RequestLimitInput): Promise<() => Promise<void>> => {
|
||||
const identity = input.userId ?? "anonymous";
|
||||
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
|
||||
const userConcurrencyKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionConcurrencyKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
|
||||
const redis = getRedisClient();
|
||||
|
||||
if (!redis) {
|
||||
enforceLocalRateLimit(rateKey, categoryLimits[input.category]);
|
||||
acquireLocalConcurrency(userConcurrencyKey, 4);
|
||||
try {
|
||||
acquireLocalConcurrency(definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
releaseLocalConcurrency(definitionConcurrencyKey);
|
||||
};
|
||||
public async incrementRate(key: string, windowMs: number) {
|
||||
const [count, retryAfterMs] = (await this.redis.eval(RATE_SCRIPT, 1, key, windowMs)) as [number, number];
|
||||
return { count: Number(count), retryAfterMs: Number(retryAfterMs) || windowMs };
|
||||
}
|
||||
|
||||
try {
|
||||
await enforceRedisRateLimit(redis, rateKey, categoryLimits[input.category]);
|
||||
await acquireRedisConcurrency(redis, userConcurrencyKey, 4);
|
||||
try {
|
||||
await acquireRedisConcurrency(redis, definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
await releaseRedisConcurrency(redis, userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget rate limiter is unavailable", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Request limiter is unavailable" });
|
||||
public async acquireConcurrency(key: string, limit: number, ttlMs: number) {
|
||||
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, limit, ttlMs)) > 0;
|
||||
}
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
releaseRedisConcurrency(redis, userConcurrencyKey),
|
||||
releaseRedisConcurrency(redis, definitionConcurrencyKey),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error("Failed to release custom widget request capacity", {
|
||||
public async releaseConcurrency(key: string) {
|
||||
await this.redis.eval(RELEASE_SCRIPT, 1, key);
|
||||
}
|
||||
}
|
||||
|
||||
let limiter: CustomWidgetRequestLimiter | undefined;
|
||||
function getLimiter() {
|
||||
if (limiter) return limiter;
|
||||
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
|
||||
const store = useLocal ? undefined : new RedisRequestLimitStore(createRedisClient());
|
||||
limiter = new CustomWidgetRequestLimiter({
|
||||
store,
|
||||
onStoreError: (error) =>
|
||||
logger.error("Custom widget rate limiter is unavailable", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}),
|
||||
});
|
||||
return limiter;
|
||||
}
|
||||
|
||||
export async function acquireCustomWidgetRequestLimit(input: RequestLimitInput): Promise<() => Promise<void>> {
|
||||
try {
|
||||
const release = await getLimiter().acquire(input);
|
||||
return async () => {
|
||||
try {
|
||||
await release();
|
||||
} catch (error) {
|
||||
logger.error("Failed to release custom widget request capacity", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,37 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
renderRequestBody as renderDomainBody,
|
||||
renderRequestTarget as renderDomainTarget,
|
||||
validateRuntimeParams as validateDomainParams,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/validation/custom-widget";
|
||||
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
|
||||
export { hashRuntimeParams };
|
||||
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
|
||||
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
|
||||
|
||||
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
const declaredNames = Object.keys(request.parameters).toSorted();
|
||||
const suppliedNames = Object.keys(params).toSorted();
|
||||
if (
|
||||
declaredNames.length !== suppliedNames.length ||
|
||||
declaredNames.some((name, index) => name !== suppliedNames[index])
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
|
||||
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
|
||||
try {
|
||||
validateDomainParams(request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
for (const [name, expectedType] of Object.entries(request.parameters)) {
|
||||
if (typeof params[name] !== expectedType) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Request parameter '${name}' must be ${expectedType}` });
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
|
||||
try {
|
||||
return renderDomainBody(request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const renderRequestBody = (
|
||||
template: CustomJsxRequest["bodyTemplate"],
|
||||
params: CustomJsxRuntimeParams,
|
||||
): string | undefined => {
|
||||
if (template === undefined) return undefined;
|
||||
const substitute = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(substitute);
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
const parameter = params[entries[0][1]];
|
||||
if (parameter === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown body parameter '${entries[0][1]}'` });
|
||||
}
|
||||
return parameter;
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(substitute(template));
|
||||
};
|
||||
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
validateRuntimeParams(request, params);
|
||||
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
|
||||
const value = params[name];
|
||||
if (value === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
|
||||
}
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
if (/[{}]/.test(path)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Path template contains an invalid placeholder" });
|
||||
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
|
||||
try {
|
||||
return renderDomainTarget(baseUrl, request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
return resolveSameOriginTarget(baseUrl, new URL(path, baseUrl));
|
||||
};
|
||||
|
||||
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
|
||||
const stableParams = Object.fromEntries(
|
||||
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { displayConfigSchema } from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
|
||||
|
||||
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
|
||||
displayConfig.template = template;
|
||||
const result = displayConfigSchema.safeParse(displayConfig);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: result.error.issues
|
||||
.map((issue) => `${issue.path.map(String).join(".") || "template"}: ${issue.message}`)
|
||||
.join("; "),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const templateProcedures = {
|
||||
readTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const config = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget readTemplate",
|
||||
);
|
||||
|
||||
if (config.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
const template = (config.template as string | undefined) ?? "";
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
writeTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
template: z.string().optional(),
|
||||
templateLines: z.array(z.string()).optional(),
|
||||
})
|
||||
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
|
||||
message: "Provide either template or templateLines",
|
||||
})
|
||||
.refine((data) => !(data.template !== undefined && data.templateLines !== undefined), {
|
||||
message: "Provide template or templateLines, not both",
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolvedTemplate =
|
||||
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
|
||||
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget writeTemplate",
|
||||
);
|
||||
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedTemplate.trim().length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
|
||||
}
|
||||
if (resolvedTemplate.length > 50_000) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
|
||||
}
|
||||
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
|
||||
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Updated custom widget template", { id: input.id });
|
||||
return {
|
||||
id: input.id,
|
||||
template: resolvedTemplate,
|
||||
templateLines: resolvedTemplate.split("\n"),
|
||||
revision: getTemplateRevision(resolvedTemplate),
|
||||
};
|
||||
}),
|
||||
|
||||
patchTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
expectedRevision: z.string().length(16),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
startLine: z.number().int().min(1),
|
||||
deleteCount: z.number().int().min(0),
|
||||
replacementLines: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
}),
|
||||
)
|
||||
.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", message: "Custom widget definition not found" });
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget patchTemplate",
|
||||
);
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
|
||||
}
|
||||
|
||||
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
|
||||
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Template changed since it was read. Read the template again and retry the patch.",
|
||||
});
|
||||
}
|
||||
|
||||
const lines = currentTemplate.split("\n");
|
||||
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
|
||||
let nextHigherStart = lines.length + 2;
|
||||
for (const edit of edits) {
|
||||
if (edit.startLine > lines.length + 1 || edit.startLine + edit.deleteCount - 1 > lines.length) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Edit at line ${edit.startLine} is out of range` });
|
||||
}
|
||||
if (edit.startLine + Math.max(edit.deleteCount, 1) > nextHigherStart) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template patch edits must not overlap" });
|
||||
}
|
||||
nextHigherStart = edit.startLine;
|
||||
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
|
||||
}
|
||||
|
||||
const template = lines.join("\n");
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
|
||||
return {
|
||||
id: input.id,
|
||||
template,
|
||||
templateLines: lines,
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const transferProcedures = {
|
||||
export: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Export one custom widget as a homarr-custom-widget-v3 object. REQUIRED: id. Secrets are excluded.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return {
|
||||
$schema: "homarr-custom-widget-v3" as const,
|
||||
name: definition.name,
|
||||
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: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig during export",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
import: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a custom widget from a validated homarr-custom-widget-v3 import object. Secrets are omitted and configured separately.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetImportSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
logger.info("Imported custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
};
|
||||
@@ -1,252 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JSONPath } from "jsonpath-plus";
|
||||
|
||||
import {
|
||||
sonarrResponse,
|
||||
sonarrStatGridConfig,
|
||||
proxmoxResponse,
|
||||
proxmoxProgressBarsConfig,
|
||||
diskUsageResponse,
|
||||
diskTableConfig,
|
||||
piholeResponse,
|
||||
piholeStatusIndicatorConfig,
|
||||
piholeCountGridConfig,
|
||||
radarrResponse,
|
||||
radarrStatGridConfig,
|
||||
jellyfinItemCountsResponse,
|
||||
jellyfinCountGridConfig,
|
||||
} from "./custom-api-fixtures";
|
||||
|
||||
const extractors: Record<string, (json: unknown, config: Record<string, unknown>) => unknown> = {
|
||||
singleValue: (json, c) => ({
|
||||
type: "singleValue",
|
||||
label: (c.label as string) ?? "",
|
||||
unit: (c.unit as string) ?? "",
|
||||
value: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
|
||||
valueSize: c.valueSize ?? "lg",
|
||||
labelPosition: c.labelPosition ?? "below",
|
||||
}),
|
||||
keyValue: (json, c) => ({
|
||||
type: "keyValue",
|
||||
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
|
||||
label: m.label,
|
||||
unit: m.unit,
|
||||
value: JSONPath({ path: m.jsonPath, json: json as object, wrap: false }),
|
||||
})),
|
||||
layout: c.layout ?? "list",
|
||||
columns: c.columns ?? 2,
|
||||
}),
|
||||
table: (json, c) => {
|
||||
const tablePath = (c.tablePath as string) ?? "$";
|
||||
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
|
||||
const rows = JSONPath({ path: tablePath, json: json as object, wrap: true }) as unknown[];
|
||||
const flatRows = Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
|
||||
return {
|
||||
type: "table",
|
||||
columns: columns.map((col) => col.header),
|
||||
rows: flatRows.map((row) =>
|
||||
columns.map((col) => JSONPath({ path: col.jsonPath, json: row as object, wrap: false })),
|
||||
),
|
||||
striped: c.striped ?? true,
|
||||
compact: c.compact ?? false,
|
||||
};
|
||||
},
|
||||
statGrid: (json, c) => ({
|
||||
type: "statGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
|
||||
}),
|
||||
),
|
||||
columns: c.columns ?? 2,
|
||||
cardStyle: c.cardStyle ?? "filled",
|
||||
}),
|
||||
progressBars: (json, c) => ({
|
||||
type: "progressBars",
|
||||
bars: (
|
||||
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
|
||||
).map((bar) => {
|
||||
const value = JSONPath({ path: bar.valuePath, json: json as object, wrap: false });
|
||||
const max = bar.maxPath ? JSONPath({ path: bar.maxPath, json: json as object, wrap: false }) : undefined;
|
||||
return {
|
||||
label: bar.label,
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
value: Number(value) || 0,
|
||||
max: max !== undefined ? Number(max) || 100 : undefined,
|
||||
};
|
||||
}),
|
||||
showPercentage: c.showPercentage ?? true,
|
||||
barSize: c.barSize ?? "md",
|
||||
}),
|
||||
statusIndicator: (json, c) => ({
|
||||
type: "statusIndicator",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
|
||||
const value = JSONPath({ path: item.jsonPath, json: json as object, wrap: false });
|
||||
const isGood = item.goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
|
||||
return { label: item.label, value: String(value ?? "unknown"), isGood };
|
||||
}),
|
||||
layout: c.layout ?? "list",
|
||||
dotSize: c.dotSize ?? "md",
|
||||
}),
|
||||
countGrid: (json, c) => ({
|
||||
type: "countGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
|
||||
})),
|
||||
columns: c.columns ?? 2,
|
||||
valueSize: c.valueSize ?? "md",
|
||||
}),
|
||||
raw: (json, c) => ({
|
||||
type: "raw",
|
||||
data: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
|
||||
maxHeight: c.maxHeight ?? 300,
|
||||
}),
|
||||
customJsx: (json, config) => ({
|
||||
type: "customJsx" as const,
|
||||
template: config.template as string,
|
||||
data: json,
|
||||
}),
|
||||
};
|
||||
|
||||
function extract(type: string, json: unknown, config: Record<string, unknown>) {
|
||||
const fn = extractors[type];
|
||||
if (!fn) throw new Error(`No extractor for type: ${type}`);
|
||||
return fn(json, config);
|
||||
}
|
||||
|
||||
describe("custom-api extractors", () => {
|
||||
it("sonarr statGrid extracts missing and queue counts", () => {
|
||||
const result = extract("statGrid", sonarrResponse, sonarrStatGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown; color: string }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("statGrid");
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]?.value).toBe(42);
|
||||
expect(result.items[0]?.label).toBe("Missing");
|
||||
expect(result.items[0]?.color).toBe("red");
|
||||
expect(result.items[1]?.value).toBe(5);
|
||||
});
|
||||
|
||||
it("proxmox progressBars extracts memory and disk usage", () => {
|
||||
const result = extract("progressBars", proxmoxResponse, proxmoxProgressBarsConfig) as {
|
||||
type: string;
|
||||
bars: Array<{ label: string; value: number; max: number }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("progressBars");
|
||||
expect(result.bars).toHaveLength(2);
|
||||
expect(result.bars[0]?.value).toBe(12_884_901_888);
|
||||
expect(result.bars[0]?.max).toBe(34_359_738_368);
|
||||
expect(result.bars[1]?.label).toBe("Disk");
|
||||
});
|
||||
|
||||
it("disk table extracts filesystem rows", () => {
|
||||
const result = extract("table", diskUsageResponse, diskTableConfig) as {
|
||||
type: string;
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
};
|
||||
|
||||
expect(result.type).toBe("table");
|
||||
expect(result.columns).toEqual(["Mount", "Size", "Used"]);
|
||||
expect(result.rows).toHaveLength(3);
|
||||
expect(result.rows[0]?.[0]).toBe("/");
|
||||
expect(result.rows[2]?.[2]).toBe("95%");
|
||||
});
|
||||
|
||||
it("pihole statusIndicator detects enabled state", () => {
|
||||
const result = extract("statusIndicator", piholeResponse, piholeStatusIndicatorConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: string; isGood: boolean }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("statusIndicator");
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]?.isGood).toBe(true);
|
||||
expect(result.items[0]?.value).toBe("enabled");
|
||||
});
|
||||
|
||||
it("pihole countGrid extracts all 4 stats", () => {
|
||||
const result = extract("countGrid", piholeResponse, piholeCountGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown; unit: string }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("countGrid");
|
||||
expect(result.items).toHaveLength(4);
|
||||
expect(result.items[0]?.value).toBe(12_847);
|
||||
expect(result.items[1]?.value).toBe(48_291);
|
||||
expect(result.items[2]?.value).toBe(26.6);
|
||||
expect(result.items[2]?.unit).toBe("%");
|
||||
expect(result.items[3]?.value).toBe(14);
|
||||
});
|
||||
|
||||
it("radarr statGrid with outline card style", () => {
|
||||
const result = extract("statGrid", radarrResponse, radarrStatGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown }>;
|
||||
cardStyle: string;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("statGrid");
|
||||
expect(result.cardStyle).toBe("outline");
|
||||
expect(result.items[0]?.value).toBe(15);
|
||||
expect(result.items[1]?.value).toBe(3);
|
||||
});
|
||||
|
||||
it("raw extractor returns raw data at path", () => {
|
||||
const result = extract("raw", piholeResponse, { type: "raw", jsonPath: "$.status", maxHeight: 200 }) as {
|
||||
type: string;
|
||||
data: unknown;
|
||||
maxHeight: number;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("raw");
|
||||
expect(result.data).toBe("enabled");
|
||||
expect(result.maxHeight).toBe(200);
|
||||
});
|
||||
|
||||
it("customJsx passes full JSON and template through", () => {
|
||||
const json = { title: "Status", items: [{ name: "CPU", value: 42 }] };
|
||||
const config = {
|
||||
type: "customJsx" as const,
|
||||
template: "<Stack><Title>{data.title}</Title></Stack>",
|
||||
};
|
||||
|
||||
const result = extract("customJsx", json, config) as {
|
||||
type: string;
|
||||
template: string;
|
||||
data: typeof json;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("customJsx");
|
||||
expect(result.template).toBe(config.template);
|
||||
expect(result.data).toEqual(json);
|
||||
});
|
||||
|
||||
it("jellyfin countGrid extracts movies/series/episodes/songs in 4-column grid", () => {
|
||||
const result = extract("countGrid", jellyfinItemCountsResponse, jellyfinCountGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown; unit: string }>;
|
||||
columns: number;
|
||||
valueSize: string;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("countGrid");
|
||||
expect(result.columns).toBe(4);
|
||||
expect(result.valueSize).toBe("lg");
|
||||
expect(result.items).toHaveLength(4);
|
||||
expect(result.items[0]).toEqual({ label: "Movies", value: 185, unit: "" });
|
||||
expect(result.items[1]).toEqual({ label: "Series", value: 38, unit: "" });
|
||||
expect(result.items[2]).toEqual({ label: "Episodes", value: 770, unit: "" });
|
||||
expect(result.items[3]).toEqual({ label: "Songs", value: 0, unit: "" });
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
export const sonarrResponse = {
|
||||
wanted: { totalRecords: 42 },
|
||||
queue: { totalRecords: 5 },
|
||||
series: [
|
||||
{ title: "Breaking Bad", seasons: 5, episodeCount: 62, episodeFileCount: 60 },
|
||||
{ title: "The Wire", seasons: 5, episodeCount: 60, episodeFileCount: 55 },
|
||||
],
|
||||
diskspace: [
|
||||
{ path: "/tv", label: "TV Shows", freeSpace: 150_000_000_000, totalSpace: 500_000_000_000 },
|
||||
{ path: "/movies", label: "Movies", freeSpace: 80_000_000_000, totalSpace: 250_000_000_000 },
|
||||
],
|
||||
};
|
||||
|
||||
export const sonarrStatGridConfig = {
|
||||
type: "statGrid" as const,
|
||||
items: [
|
||||
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
|
||||
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "blue" },
|
||||
],
|
||||
columns: 2,
|
||||
cardStyle: "filled" as const,
|
||||
};
|
||||
|
||||
export const proxmoxResponse = {
|
||||
data: {
|
||||
cpu: 0.23,
|
||||
memory: { used: 12_884_901_888, total: 34_359_738_368 },
|
||||
rootfs: { used: 42_949_672_960, total: 107_374_182_400 },
|
||||
uptime: 864_000,
|
||||
status: "running",
|
||||
maxcpu: 8,
|
||||
maxmem: 34_359_738_368,
|
||||
name: "proxmox-node1",
|
||||
},
|
||||
};
|
||||
|
||||
export const proxmoxProgressBarsConfig = {
|
||||
type: "progressBars" as const,
|
||||
bars: [
|
||||
{ label: "Memory", valuePath: "$.data.memory.used", maxPath: "$.data.memory.total", unit: "B", color: "blue" },
|
||||
{ label: "Disk", valuePath: "$.data.rootfs.used", maxPath: "$.data.rootfs.total", unit: "B", color: "orange" },
|
||||
],
|
||||
showPercentage: true,
|
||||
barSize: "md" as const,
|
||||
};
|
||||
|
||||
export const diskUsageResponse = {
|
||||
filesystems: [
|
||||
{ filesystem: "/dev/sda1", size: "500G", used: "350G", avail: "150G", usePct: "70%", mountedOn: "/" },
|
||||
{ filesystem: "/dev/sdb1", size: "2T", used: "1.2T", avail: "800G", usePct: "60%", mountedOn: "/data" },
|
||||
{ filesystem: "/dev/sdc1", size: "4T", used: "3.8T", avail: "200G", usePct: "95%", mountedOn: "/media" },
|
||||
],
|
||||
};
|
||||
|
||||
export const diskTableConfig = {
|
||||
type: "table" as const,
|
||||
tablePath: "$.filesystems",
|
||||
columns: [
|
||||
{ header: "Mount", jsonPath: "$.mountedOn" },
|
||||
{ header: "Size", jsonPath: "$.size" },
|
||||
{ header: "Used", jsonPath: "$.usePct" },
|
||||
],
|
||||
striped: true,
|
||||
compact: false,
|
||||
};
|
||||
|
||||
export const piholeResponse = {
|
||||
domains_being_blocked: 125_432,
|
||||
dns_queries_today: 48_291,
|
||||
ads_blocked_today: 12_847,
|
||||
ads_percentage_today: 26.6,
|
||||
unique_clients: 14,
|
||||
status: "enabled",
|
||||
gravity_last_updated: { relative: { days: 2, hours: 3 } },
|
||||
};
|
||||
|
||||
export const piholeStatusIndicatorConfig = {
|
||||
type: "statusIndicator" as const,
|
||||
items: [{ label: "Pi-Hole", jsonPath: "$.status", goodValues: ["enabled"] }],
|
||||
layout: "list" as const,
|
||||
dotSize: "md" as const,
|
||||
};
|
||||
|
||||
export const piholeCountGridConfig = {
|
||||
type: "countGrid" as const,
|
||||
items: [
|
||||
{ label: "Blocked Today", jsonPath: "$.ads_blocked_today", unit: "" },
|
||||
{ label: "DNS Queries", jsonPath: "$.dns_queries_today", unit: "" },
|
||||
{ label: "Block Rate", jsonPath: "$.ads_percentage_today", unit: "%" },
|
||||
{ label: "Clients", jsonPath: "$.unique_clients", unit: "" },
|
||||
],
|
||||
columns: 2,
|
||||
valueSize: "md" as const,
|
||||
};
|
||||
|
||||
export const radarrResponse = {
|
||||
wanted: { totalRecords: 15 },
|
||||
queue: { totalRecords: 3 },
|
||||
movie: [
|
||||
{ title: "Inception", hasFile: true, monitored: true, runtime: 148, year: 2010 },
|
||||
{ title: "Interstellar", hasFile: false, monitored: true, runtime: 169, year: 2014 },
|
||||
{ title: "The Dark Knight", hasFile: true, monitored: false, runtime: 152, year: 2008 },
|
||||
],
|
||||
rootfolder: [{ path: "/movies", freeSpace: 200_000_000_000, totalSpace: 1_000_000_000_000 }],
|
||||
};
|
||||
|
||||
export const radarrStatGridConfig = {
|
||||
type: "statGrid" as const,
|
||||
items: [
|
||||
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
|
||||
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "teal" },
|
||||
],
|
||||
columns: 2,
|
||||
cardStyle: "outline" as const,
|
||||
};
|
||||
|
||||
export const jellyfinItemCountsResponse = {
|
||||
MovieCount: 185,
|
||||
SeriesCount: 38,
|
||||
EpisodeCount: 770,
|
||||
ArtistCount: 0,
|
||||
ProgramCount: 0,
|
||||
TrailerCount: 0,
|
||||
SongCount: 0,
|
||||
AlbumCount: 0,
|
||||
MusicVideoCount: 0,
|
||||
BoxSetCount: 12,
|
||||
BookCount: 0,
|
||||
ItemCount: 0,
|
||||
};
|
||||
|
||||
export const jellyfinCountGridConfig = {
|
||||
type: "countGrid" as const,
|
||||
items: [
|
||||
{ label: "Movies", jsonPath: "$.MovieCount", unit: "" },
|
||||
{ label: "Series", jsonPath: "$.SeriesCount", unit: "" },
|
||||
{ label: "Episodes", jsonPath: "$.EpisodeCount", unit: "" },
|
||||
{ label: "Songs", jsonPath: "$.SongCount", unit: "" },
|
||||
],
|
||||
columns: 4,
|
||||
valueSize: "lg" as const,
|
||||
};
|
||||
@@ -7,12 +7,16 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { customJsxDisplayConfigV2Schema, displayConfigSchema } from "@homarr/validation/custom-widget";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/validation/custom-widget";
|
||||
import {
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
extractActionButtonDisplay,
|
||||
extractDisplayDataWithFallback,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
|
||||
import { executeCustomWidgetRequest } from "../custom-widget/request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
|
||||
|
||||
@@ -62,3 +62,31 @@ test("MCP tools should have descriptions", () => {
|
||||
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("custom widget public procedures remain available", () => {
|
||||
expect(Object.keys(customWidgetRouter._def.procedures)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"all",
|
||||
"byId",
|
||||
"available",
|
||||
"create",
|
||||
"update",
|
||||
"toggleEnabled",
|
||||
"delete",
|
||||
"duplicate",
|
||||
"export",
|
||||
"import",
|
||||
"schema",
|
||||
"validate",
|
||||
"readTemplate",
|
||||
"writeTemplate",
|
||||
"patchTemplate",
|
||||
"preview",
|
||||
"previewQuery",
|
||||
"previewAction",
|
||||
"simulatePreviewAction",
|
||||
"setPreviewLiveActions",
|
||||
"previewJournal",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@homarr/custom-widgets",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"check:architecture": "node ./scripts/check-architecture.mjs",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/charts": "catalog:",
|
||||
"@mantine/core": "catalog:",
|
||||
"@mantine/dates": "catalog:",
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"acorn": "catalog:",
|
||||
"acorn-jsx": "catalog:",
|
||||
"jsonpath-plus": "catalog:",
|
||||
"react": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, join, relative, resolve } from "node:path";
|
||||
|
||||
const packageRoot = resolve(import.meta.dirname, "..");
|
||||
const repositoryRoot = resolve(packageRoot, "../..");
|
||||
const sourceRoots = [
|
||||
join(packageRoot, "src"),
|
||||
join(repositoryRoot, "packages/api/src/router/custom-widget"),
|
||||
join(repositoryRoot, "packages/widgets/src/custom-api"),
|
||||
join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets"),
|
||||
];
|
||||
const productionFiles = [];
|
||||
|
||||
async function collect(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "test") await collect(path);
|
||||
} else if ([".ts", ".tsx"].includes(extname(entry.name)) && !/\.spec\.tsx?$/u.test(entry.name)) {
|
||||
productionFiles.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourceRoot of sourceRoots) await collect(sourceRoot);
|
||||
const failures = [];
|
||||
const graph = new Map();
|
||||
const algorithmicExceptions = new Map([
|
||||
["packages/custom-widgets/src/jsx/analyzer.ts", 400],
|
||||
["packages/custom-widgets/src/jsx/interpreter.tsx", 400],
|
||||
]);
|
||||
|
||||
for (const file of productionFiles) {
|
||||
const source = await readFile(file, "utf8");
|
||||
const name = relative(repositoryRoot, file);
|
||||
const lineCount = source.trimEnd().split("\n").length;
|
||||
const limit = algorithmicExceptions.get(name) ?? 300;
|
||||
if (lineCount > limit) failures.push(`${name} has ${lineCount} lines (limit ${limit})`);
|
||||
if (file.startsWith(join(packageRoot, "src")) && /from\s+["']@homarr\/(?:api|widgets)(?:\/|["'])/u.test(source)) {
|
||||
failures.push(`${name} imports a forbidden adapter package`);
|
||||
}
|
||||
if (extname(file) === ".tsx" && /(?:runtime|workbench|custom-widgets|custom-api)/u.test(name)) {
|
||||
for (const line of source.split("\n")) {
|
||||
const match = line.match(/>\s*([A-Za-z][A-Za-z0-9 .,!?'-]*)\s*</u);
|
||||
if (match) failures.push(`${name} contains untranslated JSX text: ${JSON.stringify(match[1]?.trim())}`);
|
||||
}
|
||||
if (/\b(?:aria-label|description|label|placeholder|title)=["'][A-Za-z]/u.test(source)) {
|
||||
failures.push(`${name} contains an untranslated user-facing attribute`);
|
||||
}
|
||||
}
|
||||
const dependencies = [];
|
||||
for (const match of source.matchAll(/from\s+["'](\.[^"']+)["']/gu)) {
|
||||
const candidate = resolve(file, "..", match[1]);
|
||||
const resolved = productionFiles.find(
|
||||
(item) =>
|
||||
item === candidate ||
|
||||
item === `${candidate}.ts` ||
|
||||
item === `${candidate}.tsx` ||
|
||||
item === join(candidate, "index.ts"),
|
||||
);
|
||||
if (resolved) dependencies.push(resolved);
|
||||
}
|
||||
graph.set(file, dependencies);
|
||||
}
|
||||
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
function visit(file, path = []) {
|
||||
if (visiting.has(file)) {
|
||||
failures.push(`circular dependency: ${[...path, file].map((item) => relative(repositoryRoot, item)).join(" -> ")}`);
|
||||
return;
|
||||
}
|
||||
if (visited.has(file)) return;
|
||||
visiting.add(file);
|
||||
for (const dependency of graph.get(file) ?? []) visit(dependency, [...path, file]);
|
||||
visiting.delete(file);
|
||||
visited.add(file);
|
||||
}
|
||||
for (const file of productionFiles) visit(file);
|
||||
|
||||
const widgetAdapter = await readFile(join(repositoryRoot, "packages/widgets/src/custom-api/component.tsx"), "utf8");
|
||||
if (!/dynamic\(\(\)\s*=>\s*import\(["']\.\/custom-jsx-display["']\)/u.test(widgetAdapter)) {
|
||||
failures.push("Custom JSX board rendering must remain dynamically loaded");
|
||||
}
|
||||
const editorAdapter = await readFile(
|
||||
join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
if (!/dynamic\(\(\)\s*=>\s*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) {
|
||||
failures.push("CodeMirror must remain dynamically loaded");
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join("\n"));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Custom Widgets architecture checks passed for ${productionFiles.length} production modules.`);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { enabledCustomJsxComponents } from "./component-registry";
|
||||
|
||||
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
|
||||
|
||||
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
|
||||
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
|
||||
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
|
||||
|
||||
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
|
||||
|
||||
## Rules
|
||||
- Use \`"$schema": "homarr-custom-widget-v3"\`.
|
||||
- \`displayType\` and \`displayConfig.type\` must match.
|
||||
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
|
||||
- The base widget request must use GET. Mutations belong in named action requests.
|
||||
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
|
||||
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
|
||||
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
|
||||
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
|
||||
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
|
||||
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
|
||||
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
|
||||
- Use only components in the generated component reference below.
|
||||
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
|
||||
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
|
||||
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
|
||||
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
|
||||
|
||||
## Named request shape
|
||||
\`\`\`ts
|
||||
interface CustomJsxRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
pathTemplate: \`/\${string}\`;
|
||||
parameters: Record<string, "string" | "number" | "boolean">;
|
||||
bodyTemplate?: unknown;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth: "inherit" | "none";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
|
||||
|
||||
## SubFetch rendering
|
||||
Use a render child when a named query needs its own layout:
|
||||
|
||||
\`\`\`jsx
|
||||
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>{detail.name}</Text>
|
||||
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
|
||||
</Group>
|
||||
{detail.metrics.map((metric) =>
|
||||
<Group key={metric.name} justify="space-between">
|
||||
<Text size="sm">{metric.name}</Text>
|
||||
<Text fw={600}>{metric.value}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
function buildComponentReference() {
|
||||
const categories = new Map<string, string[]>();
|
||||
for (const component of enabledCustomJsxComponents) {
|
||||
const names = categories.get(component.category) ?? [];
|
||||
names.push(component.name);
|
||||
categories.set(component.category, names);
|
||||
}
|
||||
|
||||
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
|
||||
}
|
||||
|
||||
function buildExamples() {
|
||||
return customJsxExamples
|
||||
.map(
|
||||
(example) => `### ${example.title}
|
||||
${example.description}
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
requests: example.requests,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
\`\`\`jsx
|
||||
${example.template}
|
||||
\`\`\``,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildCustomWidgetAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const responseSection = rawResponse
|
||||
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
|
||||
const currentDisplayConfig = currentConfig?.displayConfig;
|
||||
const currentTemplate =
|
||||
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
|
||||
? currentDisplayConfig.template
|
||||
: null;
|
||||
const readableCurrentConfig = currentConfig
|
||||
? {
|
||||
...currentConfig,
|
||||
...(currentTemplate && typeof currentTemplate === "string"
|
||||
? {
|
||||
displayConfig: {
|
||||
...(currentDisplayConfig as Record<string, unknown>),
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const configSection = readableCurrentConfig
|
||||
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
|
||||
: "";
|
||||
|
||||
return `${PROMPT_HEADER}
|
||||
## Import schema
|
||||
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(jsonSchema, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Generated component reference
|
||||
${buildComponentReference()}
|
||||
|
||||
## Tested examples
|
||||
${buildExamples()}
|
||||
|
||||
${responseSection}
|
||||
${configSection}
|
||||
## Requested widget
|
||||
Describe what the widget should show and which interactions it needs:
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const singleValueDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("singleValue")
|
||||
.describe("Display type discriminator — must be 'singleValue' when displayType is singleValue"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the value from the API response (e.g. $.data.count, $.items[0].name, $.status). Use $ for the root object.",
|
||||
),
|
||||
label: z
|
||||
.string()
|
||||
.describe("Human-readable label shown alongside the extracted value (e.g. 'CPU Temperature', 'Total Users')"),
|
||||
unit: z
|
||||
.string()
|
||||
.describe("Unit suffix displayed after the value (e.g. '°C', '%', 'GB'). Leave empty string if no unit is needed."),
|
||||
valueSize: z
|
||||
.enum(["sm", "md", "lg", "xl"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Font size of the displayed value: 'sm' (small), 'md' (medium), 'lg' (large, default), 'xl' (extra large)",
|
||||
),
|
||||
labelPosition: z
|
||||
.enum(["above", "below"])
|
||||
.optional()
|
||||
.describe("Position of the label relative to the value: 'above' or 'below' (default: below)"),
|
||||
});
|
||||
|
||||
const keyValueMappingSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this key-value pair (e.g. 'CPU', 'Memory', 'Uptime')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract this value from the API response (e.g. $.cpu.percent, $.memory.used). Use $ for the root object.",
|
||||
),
|
||||
unit: z
|
||||
.string()
|
||||
.describe("Unit suffix for this value (e.g. '%', 'GB', 'ms'). Leave empty string if no unit is needed."),
|
||||
});
|
||||
|
||||
export const keyValueDisplayConfigSchema = z.object({
|
||||
type: z.literal("keyValue").describe("Display type discriminator — must be 'keyValue' when displayType is keyValue"),
|
||||
mappings: z
|
||||
.array(keyValueMappingSchema)
|
||||
.min(1)
|
||||
.describe("Array of label/jsonPath/unit mappings — each entry becomes one labeled value in the widget"),
|
||||
layout: z
|
||||
.enum(["list", "grid"])
|
||||
.optional()
|
||||
.describe("Layout style: 'list' (vertical stack, default) or 'grid' (multi-column grid)"),
|
||||
columns: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(3)
|
||||
.optional()
|
||||
.describe("Number of columns when layout is 'grid' (1–3, default: 2)"),
|
||||
});
|
||||
|
||||
const tableColumnSchema = z.object({
|
||||
header: z.string().min(1).describe("Column header text shown in the table (e.g. 'Name', 'Status', 'Size')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression relative to each row object to extract the cell value (e.g. $.name, $.status, $.size). Do not include the array path here — that goes in tablePath.",
|
||||
),
|
||||
});
|
||||
|
||||
export const tableDisplayConfigSchema = z.object({
|
||||
type: z.literal("table").describe("Display type discriminator — must be 'table' when displayType is table"),
|
||||
tablePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to the array of row objects in the API response (e.g. $.data.items, $.results, $.users[*])",
|
||||
),
|
||||
columns: z
|
||||
.array(tableColumnSchema)
|
||||
.min(1)
|
||||
.describe("Column definitions — each column has a header and a jsonPath relative to each row"),
|
||||
striped: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Whether to alternate row background colors for readability (default: true)"),
|
||||
compact: z.boolean().optional().describe("Whether to use compact row spacing (default: false)"),
|
||||
});
|
||||
|
||||
const statGridItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this stat card (e.g. 'Movies', 'Series', 'Episodes')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the stat value from the API response (e.g. $.library.movies, $.counts.series)",
|
||||
),
|
||||
unit: z.string().describe("Unit suffix for the stat (e.g. '', 'GB', '%'). Leave empty string if no unit is needed."),
|
||||
color: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Mantine color name for the stat card (e.g. 'blue', 'green', 'red', 'orange', 'violet'). Default: blue"),
|
||||
});
|
||||
|
||||
export const statGridDisplayConfigSchema = z.object({
|
||||
type: z.literal("statGrid").describe("Display type discriminator — must be 'statGrid' when displayType is statGrid"),
|
||||
items: z
|
||||
.array(statGridItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of stat cards — each item shows a labeled value in a colored card"),
|
||||
columns: z.number().int().min(1).max(4).optional().describe("Number of columns in the stat grid (1–4, default: 2)"),
|
||||
cardStyle: z
|
||||
.enum(["filled", "outline", "subtle"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Visual style of stat cards: 'filled' (solid background, default), 'outline' (border only), 'subtle' (light tint)",
|
||||
),
|
||||
});
|
||||
|
||||
const progressBarItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this progress bar (e.g. 'Disk Usage', 'Memory', 'Quota')"),
|
||||
valuePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the current value (numerator) from the API response (e.g. $.disk.used, $.memory.used_bytes)",
|
||||
),
|
||||
maxPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"JSONPath expression to extract the maximum value (denominator). If omitted, defaults to 100. Use for ratios like used/total (e.g. $.disk.total)",
|
||||
),
|
||||
unit: z
|
||||
.string()
|
||||
.describe("Unit suffix shown alongside values (e.g. 'GB', 'MB', '%'). Leave empty string if no unit is needed."),
|
||||
color: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Mantine color name for the progress bar fill (e.g. 'blue', 'green', 'red'). Default: blue"),
|
||||
});
|
||||
|
||||
export const progressBarsDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("progressBars")
|
||||
.describe("Display type discriminator — must be 'progressBars' when displayType is progressBars"),
|
||||
bars: z
|
||||
.array(progressBarItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of progress bar definitions — each bar shows value/max as a visual progress indicator"),
|
||||
showPercentage: z.boolean().optional().describe("Whether to display the percentage next to each bar (default: true)"),
|
||||
barSize: z
|
||||
.enum(["sm", "md", "lg"])
|
||||
.optional()
|
||||
.describe("Height of progress bars: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
|
||||
});
|
||||
|
||||
const statusIndicatorItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this status indicator (e.g. 'Web Server', 'Database', 'API')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the status value from the API response (e.g. $.services.web.status, $.health.database)",
|
||||
),
|
||||
goodValues: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe(
|
||||
"Array of string values considered 'healthy/good' — case-insensitive match (e.g. ['online', 'up', 'true', 'healthy', 'ok'])",
|
||||
),
|
||||
});
|
||||
|
||||
export const statusIndicatorDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("statusIndicator")
|
||||
.describe("Display type discriminator — must be 'statusIndicator' when displayType is statusIndicator"),
|
||||
items: z
|
||||
.array(statusIndicatorItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of status indicators — each shows a green dot if the value matches goodValues, red otherwise"),
|
||||
layout: z
|
||||
.enum(["list", "grid"])
|
||||
.optional()
|
||||
.describe("Layout style: 'list' (vertical, default) or 'grid' (multi-column)"),
|
||||
dotSize: z
|
||||
.enum(["sm", "md", "lg"])
|
||||
.optional()
|
||||
.describe("Size of status dots: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
|
||||
});
|
||||
|
||||
const countGridItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this count (e.g. 'Movies', 'Shows', 'Albums')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the count value from the API response (e.g. $.counts.movies, $.stats.total)",
|
||||
),
|
||||
unit: z.string().describe("Unit suffix for the count (e.g. '', 'items'). Leave empty string if no unit is needed."),
|
||||
});
|
||||
|
||||
export const countGridDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("countGrid")
|
||||
.describe("Display type discriminator — must be 'countGrid' when displayType is countGrid"),
|
||||
items: z
|
||||
.array(countGridItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of count items — each shows a number with a label in a simple grid (no colors)"),
|
||||
columns: z.number().int().min(2).max(4).optional().describe("Number of columns in the count grid (2–4, default: 2)"),
|
||||
valueSize: z
|
||||
.enum(["sm", "md", "lg"])
|
||||
.optional()
|
||||
.describe("Font size of count values: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
|
||||
});
|
||||
|
||||
export const rawDisplayConfigSchema = z.object({
|
||||
type: z.literal("raw").describe("Display type discriminator — must be 'raw' when displayType is raw"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the data to display (e.g. $ for entire response, $.data, $.result). The extracted value is pretty-printed as JSON.",
|
||||
),
|
||||
maxHeight: z
|
||||
.number()
|
||||
.int()
|
||||
.min(50)
|
||||
.max(1000)
|
||||
.optional()
|
||||
.describe("Maximum height of the raw JSON display area in pixels (50–1000, default: 300)"),
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { categorySafeProps, commonSafeProps } from "./component-props";
|
||||
import type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "./component-types";
|
||||
export type * from "./component-types";
|
||||
|
||||
export interface CustomJsxComponentDescriptor {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
supportedProps: readonly string[];
|
||||
subcomponents: readonly string[];
|
||||
accessibilityRequirements: readonly string[];
|
||||
documentationUrl: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export const subcomponentsByName: Readonly<Record<string, readonly string[]>> = {
|
||||
Accordion: ["Accordion.Item", "Accordion.Control", "Accordion.Panel"],
|
||||
Avatar: ["Avatar.Group"],
|
||||
Card: ["Card.Section"],
|
||||
Chip: ["Chip.Group"],
|
||||
DataList: ["DataList.Item", "DataList.ItemLabel", "DataList.ItemValue"],
|
||||
EmptyState: ["EmptyState.Indicator", "EmptyState.Title", "EmptyState.Description", "EmptyState.Actions"],
|
||||
Grid: ["Grid.Col"],
|
||||
List: ["List.Item"],
|
||||
Menu: ["Menu.Target", "Menu.Dropdown", "Menu.Item", "Menu.Label", "Menu.Divider"],
|
||||
Progress: ["Progress.Section"],
|
||||
Table: ["Table.Thead", "Table.Tbody", "Table.Tfoot", "Table.Caption", "Table.Tr", "Table.Th", "Table.Td"],
|
||||
Tabs: ["Tabs.List", "Tabs.Tab", "Tabs.Panel"],
|
||||
Timeline: ["Timeline.Item"],
|
||||
};
|
||||
|
||||
export const accessibilityByCategory: Record<CustomJsxComponentCategory, readonly string[]> = {
|
||||
layout: ["Keep visual order consistent with reading order."],
|
||||
content: ["Provide meaningful text alternatives for non-text content."],
|
||||
feedback: ["Do not rely on color alone to communicate status."],
|
||||
navigation: ["Provide an accessible label for controls without visible text."],
|
||||
charts: ["Include a nearby text summary of the chart data."],
|
||||
dates: ["Include a textual date when the visual calendar carries meaning."],
|
||||
interaction: ["Provide a visible label or aria-label."],
|
||||
network: ["Action labels must describe their effect; destructive actions require confirmation."],
|
||||
blocked: [],
|
||||
};
|
||||
|
||||
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
|
||||
"Tabs.List": ["grow"],
|
||||
ScrollArea: ["offsetScrollbars"],
|
||||
SubData: ["alt", "fit"],
|
||||
};
|
||||
|
||||
export function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
|
||||
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
|
||||
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
|
||||
const slug = (name.split(".")[0] ?? "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
return `https://mantine.dev/${section}/${slug}/`;
|
||||
}
|
||||
|
||||
export const describe = (
|
||||
packageName: CustomJsxComponentPackage,
|
||||
category: CustomJsxComponentCategory,
|
||||
safety: CustomJsxComponentSafety,
|
||||
names: readonly string[],
|
||||
reason?: string,
|
||||
): CustomJsxComponentDescriptor[] =>
|
||||
names.map((name) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
supportedProps:
|
||||
safety === "denied"
|
||||
? []
|
||||
: [
|
||||
...new Set<string>([
|
||||
...commonSafeProps,
|
||||
...categorySafeProps[category],
|
||||
...(componentSafeProps[name] ?? []),
|
||||
]),
|
||||
],
|
||||
subcomponents: subcomponentsByName[name] ?? [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
reason,
|
||||
}));
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { CustomJsxComponentCategory } from "./component-types";
|
||||
|
||||
export const commonSafeProps = [
|
||||
"key",
|
||||
"id",
|
||||
"style",
|
||||
"title",
|
||||
"role",
|
||||
"aria-label",
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"tabIndex",
|
||||
"w",
|
||||
"h",
|
||||
"miw",
|
||||
"maw",
|
||||
"mih",
|
||||
"mah",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mr",
|
||||
"mb",
|
||||
"ml",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pr",
|
||||
"pb",
|
||||
"pl",
|
||||
"bg",
|
||||
"c",
|
||||
"opacity",
|
||||
"display",
|
||||
"hiddenFrom",
|
||||
"visibleFrom",
|
||||
"bd",
|
||||
"bdrs",
|
||||
"flex",
|
||||
"fz",
|
||||
"fw",
|
||||
"lh",
|
||||
"lts",
|
||||
"ta",
|
||||
"td",
|
||||
"tt",
|
||||
"radius",
|
||||
"shadow",
|
||||
"variant",
|
||||
"color",
|
||||
"size",
|
||||
"withBorder",
|
||||
] as const;
|
||||
|
||||
export const categorySafeProps: Record<CustomJsxComponentCategory, readonly string[]> = {
|
||||
layout: [
|
||||
"gap",
|
||||
"align",
|
||||
"justify",
|
||||
"wrap",
|
||||
"direction",
|
||||
"grow",
|
||||
"cols",
|
||||
"spacing",
|
||||
"verticalSpacing",
|
||||
"gutter",
|
||||
"span",
|
||||
"offset",
|
||||
"order",
|
||||
"type",
|
||||
"orientation",
|
||||
"withBorder",
|
||||
"shadow",
|
||||
"radius",
|
||||
],
|
||||
content: [
|
||||
"alt",
|
||||
"src",
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
"size",
|
||||
"fw",
|
||||
"fs",
|
||||
"td",
|
||||
"tt",
|
||||
"ta",
|
||||
"lts",
|
||||
"lh",
|
||||
"lineClamp",
|
||||
"truncate",
|
||||
"inherit",
|
||||
"gradient",
|
||||
"variant",
|
||||
"color",
|
||||
"value",
|
||||
"prefix",
|
||||
"suffix",
|
||||
"decimalScale",
|
||||
"thousandSeparator",
|
||||
"striped",
|
||||
"highlightOnHover",
|
||||
"withTableBorder",
|
||||
"withColumnBorders",
|
||||
"type",
|
||||
"icon",
|
||||
"image",
|
||||
"order",
|
||||
"fit",
|
||||
"fallbackSrc",
|
||||
"loading",
|
||||
"referrerPolicy",
|
||||
"crossOrigin",
|
||||
"decoding",
|
||||
"draggable",
|
||||
"center",
|
||||
"spacing",
|
||||
"listStyleType",
|
||||
],
|
||||
feedback: ["color", "variant", "radius", "size", "value", "sections", "label", "animated", "striped"],
|
||||
navigation: [
|
||||
"value",
|
||||
"defaultValue",
|
||||
"active",
|
||||
"label",
|
||||
"description",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"opened",
|
||||
"disabled",
|
||||
"multiple",
|
||||
"chevronPosition",
|
||||
"orientation",
|
||||
"keepMounted",
|
||||
"transitionDuration",
|
||||
],
|
||||
charts: [
|
||||
"data",
|
||||
"dataKey",
|
||||
"series",
|
||||
"type",
|
||||
"curveType",
|
||||
"color",
|
||||
"colors",
|
||||
"withLegend",
|
||||
"withTooltip",
|
||||
"withXAxis",
|
||||
"withYAxis",
|
||||
"withDots",
|
||||
"gridAxis",
|
||||
"unit",
|
||||
"valueFormatter",
|
||||
"strokeWidth",
|
||||
"fillOpacity",
|
||||
"connectNulls",
|
||||
"tooltipAnimationDuration",
|
||||
],
|
||||
dates: [
|
||||
"date",
|
||||
"defaultDate",
|
||||
"value",
|
||||
"minDate",
|
||||
"maxDate",
|
||||
"locale",
|
||||
"firstDayOfWeek",
|
||||
"weekendDays",
|
||||
"hideOutsideDates",
|
||||
"hideWeekdays",
|
||||
"showWeekNumbers",
|
||||
"numberOfColumns",
|
||||
"level",
|
||||
"size",
|
||||
],
|
||||
interaction: [
|
||||
"label",
|
||||
"value",
|
||||
"values",
|
||||
"data",
|
||||
"checked",
|
||||
"defaultChecked",
|
||||
"disabled",
|
||||
"readOnly",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"loading",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"total",
|
||||
"page",
|
||||
"pageSize",
|
||||
"defaultTab",
|
||||
"title",
|
||||
"defaultOpen",
|
||||
"type",
|
||||
"fullWidth",
|
||||
],
|
||||
network: [
|
||||
"requestId",
|
||||
"params",
|
||||
"onParams",
|
||||
"offParams",
|
||||
"initialValue",
|
||||
"label",
|
||||
"loadingLabel",
|
||||
"successMessage",
|
||||
"errorMessage",
|
||||
"confirmMessage",
|
||||
"invalidate",
|
||||
"refreshInterval",
|
||||
"trigger",
|
||||
"render",
|
||||
"path",
|
||||
"as",
|
||||
"fallback",
|
||||
"icon",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"disabled",
|
||||
],
|
||||
blocked: [],
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe } from "./component-descriptor";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
|
||||
export const customJsxDeniedComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AppShell",
|
||||
"Affix",
|
||||
"ComboboxPopover",
|
||||
"Dialog",
|
||||
"Drawer",
|
||||
"FloatingWindow",
|
||||
"FocusTrap",
|
||||
"Menubar",
|
||||
"Modal",
|
||||
"ModalBase",
|
||||
"Portal",
|
||||
"Popover",
|
||||
"RemoveScroll",
|
||||
],
|
||||
"Escapes the widget layout or focus boundary",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AngleSlider",
|
||||
"Autocomplete",
|
||||
"Checkbox",
|
||||
"ColorInput",
|
||||
"ColorPicker",
|
||||
"Combobox",
|
||||
"FileButton",
|
||||
"FileInput",
|
||||
"Input",
|
||||
"InputBase",
|
||||
"JsonInput",
|
||||
"MaskInput",
|
||||
"MultiSelect",
|
||||
"NativeSelect",
|
||||
"NumberInput",
|
||||
"PasswordInput",
|
||||
"PillsInput",
|
||||
"PinInput",
|
||||
"Radio",
|
||||
"Select",
|
||||
"TagsInput",
|
||||
"Textarea",
|
||||
"TextInput",
|
||||
"TreeSelect",
|
||||
"UnstyledButton",
|
||||
],
|
||||
"Arbitrary form controls are not connected to the declarative action model",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
["OverflowList", "Scroller", "Splitter", "TableOfContents"],
|
||||
"Requires callback, document, or layout capabilities that are not exposed to templates",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AccordionChevron",
|
||||
"ActionIconGroup",
|
||||
"ActionIconGroupSection",
|
||||
"AlphaSlider",
|
||||
"AppShellAside",
|
||||
"AppShellFooter",
|
||||
"AppShellHeader",
|
||||
"AppShellMain",
|
||||
"AppShellNavbar",
|
||||
"AppShellSection",
|
||||
"ButtonGroup",
|
||||
"ButtonGroupSection",
|
||||
"CheckIcon",
|
||||
"CheckboxCard",
|
||||
"CheckboxGroup",
|
||||
"CheckboxIndicator",
|
||||
"CloseIcon",
|
||||
"ColorSchemeScript",
|
||||
"ComboboxChevron",
|
||||
"ComboboxClearButton",
|
||||
"ComboboxDropdown",
|
||||
"ComboboxDropdownTarget",
|
||||
"ComboboxEmpty",
|
||||
"ComboboxEventsTarget",
|
||||
"ComboboxFooter",
|
||||
"ComboboxGroup",
|
||||
"ComboboxHeader",
|
||||
"ComboboxHiddenInput",
|
||||
"ComboboxOption",
|
||||
"ComboboxOptions",
|
||||
"ComboboxPopoverTarget",
|
||||
"ComboboxSearch",
|
||||
"ComboboxTarget",
|
||||
"DirectionProvider",
|
||||
"DrawerBody",
|
||||
"DrawerCloseButton",
|
||||
"DrawerContent",
|
||||
"DrawerHeader",
|
||||
"DrawerOverlay",
|
||||
"DrawerRoot",
|
||||
"DrawerStack",
|
||||
"DrawerTitle",
|
||||
"FlatTreeNode",
|
||||
"FloatingArrow",
|
||||
"FocusTrapInitialFocus",
|
||||
"HeadlessMantineProvider",
|
||||
"HoverCardGroup",
|
||||
"HueSlider",
|
||||
"InlineStyles",
|
||||
"InputClearButton",
|
||||
"InputClearSection",
|
||||
"InputDescription",
|
||||
"InputError",
|
||||
"InputLabel",
|
||||
"InputPlaceholder",
|
||||
"InputSuccess",
|
||||
"InputWrapper",
|
||||
"MantineProvider",
|
||||
"MantineThemeProvider",
|
||||
"MenuCheckboxGroup",
|
||||
"MenuCheckboxItem",
|
||||
"MenuContextMenu",
|
||||
"MenuRadioGroup",
|
||||
"MenuRadioItem",
|
||||
"MenuSearch",
|
||||
"MenuSub",
|
||||
"MenuSubDropdown",
|
||||
"MenuSubItem",
|
||||
"MenuSubTarget",
|
||||
"MenubarDropdown",
|
||||
"MenubarMenu",
|
||||
"MenubarTarget",
|
||||
"ModalBaseBody",
|
||||
"ModalBaseCloseButton",
|
||||
"ModalBaseContent",
|
||||
"ModalBaseHeader",
|
||||
"ModalBaseOverlay",
|
||||
"ModalBaseTitle",
|
||||
"ModalBody",
|
||||
"ModalCloseButton",
|
||||
"ModalContent",
|
||||
"ModalHeader",
|
||||
"ModalOverlay",
|
||||
"ModalRoot",
|
||||
"ModalStack",
|
||||
"ModalTitle",
|
||||
"NativeScrollArea",
|
||||
"OptionalPortal",
|
||||
"OptionsDropdown",
|
||||
"PaginationControl",
|
||||
"PaginationDots",
|
||||
"PaginationFirst",
|
||||
"PaginationItems",
|
||||
"PaginationLabel",
|
||||
"PaginationLast",
|
||||
"PaginationNext",
|
||||
"PaginationPrevious",
|
||||
"PaginationRoot",
|
||||
"PillGroup",
|
||||
"PillsInputField",
|
||||
"PopoverContextMenu",
|
||||
"PopoverDropdown",
|
||||
"PopoverTarget",
|
||||
"ProgressLabel",
|
||||
"ProgressRoot",
|
||||
"RadioCard",
|
||||
"RadioGroup",
|
||||
"RadioIcon",
|
||||
"RadioIndicator",
|
||||
"RangeSlider",
|
||||
"ScrollAreaAutosize",
|
||||
"SplitterPane",
|
||||
"StepperCompleted",
|
||||
"SwitchGroup",
|
||||
"TableScrollContainer",
|
||||
"TooltipFloating",
|
||||
"TooltipGroup",
|
||||
"TreeSelectOption",
|
||||
],
|
||||
"Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/charts",
|
||||
"blocked",
|
||||
"denied",
|
||||
["AreaGradient"],
|
||||
"Low-level chart implementation helper that is not a standalone declarative display component",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/dates",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"DatesProvider",
|
||||
"HiddenDatesInput",
|
||||
"TimeInput",
|
||||
"TimePicker",
|
||||
"PickerInputBase",
|
||||
"DateInput",
|
||||
"DateTimePicker",
|
||||
"InlineDateTimePicker",
|
||||
"YearPickerInput",
|
||||
"MonthPickerInput",
|
||||
"DatePickerInput",
|
||||
],
|
||||
"Input and provider components are outside the read-only template surface",
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The source of truth for the Custom JSX public component surface.
|
||||
*
|
||||
* Components marked `wrapped` require a runtime wrapper that removes unsafe props
|
||||
* or supplies inert/read-only behavior. `denied` entries are deliberately kept in
|
||||
* the registry so Mantine upgrades cannot silently expand the template surface.
|
||||
*/
|
||||
export const customJsxEnabledComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
|
||||
...describe("@mantine/core", "layout", "allowed", [
|
||||
"Box",
|
||||
"Stack",
|
||||
"Group",
|
||||
"Flex",
|
||||
"Grid",
|
||||
"Grid.Col",
|
||||
"SimpleGrid",
|
||||
"Center",
|
||||
"Space",
|
||||
"Container",
|
||||
"AspectRatio",
|
||||
"ScrollArea",
|
||||
"Paper",
|
||||
"Card",
|
||||
"Card.Section",
|
||||
"Divider",
|
||||
"Fieldset",
|
||||
"VisuallyHidden",
|
||||
"Typography",
|
||||
"FloatingIndicator",
|
||||
]),
|
||||
...describe("@mantine/core", "content", "allowed", [
|
||||
"Text",
|
||||
"Title",
|
||||
"Code",
|
||||
"Highlight",
|
||||
"Mark",
|
||||
"Kbd",
|
||||
"Blockquote",
|
||||
"NumberFormatter",
|
||||
"Badge",
|
||||
"ThemeIcon",
|
||||
"ColorSwatch",
|
||||
"Table",
|
||||
"Table.Thead",
|
||||
"Table.Tbody",
|
||||
"Table.Tfoot",
|
||||
"Table.Caption",
|
||||
"Table.Tr",
|
||||
"Table.Th",
|
||||
"Table.Td",
|
||||
"List",
|
||||
"List.Item",
|
||||
"Timeline",
|
||||
"Timeline.Item",
|
||||
"Indicator",
|
||||
"Pill",
|
||||
"Image",
|
||||
"Avatar",
|
||||
"Avatar.Group",
|
||||
"BackgroundImage",
|
||||
"DataList",
|
||||
"DataList.Item",
|
||||
"DataList.ItemLabel",
|
||||
"DataList.ItemValue",
|
||||
"EmptyState",
|
||||
"EmptyState.Indicator",
|
||||
"EmptyState.Title",
|
||||
"EmptyState.Description",
|
||||
"EmptyState.Actions",
|
||||
"Marquee",
|
||||
"RollingNumber",
|
||||
]),
|
||||
...describe("@mantine/core", "feedback", "allowed", [
|
||||
"Alert",
|
||||
"Progress",
|
||||
"Progress.Section",
|
||||
"RingProgress",
|
||||
"SemiCircleProgress",
|
||||
"Skeleton",
|
||||
"Loader",
|
||||
]),
|
||||
...describe("@mantine/core", "feedback", "wrapped", ["Notification", "LoadingOverlay", "Overlay"]),
|
||||
...describe("@mantine/core", "navigation", "allowed", [
|
||||
"Accordion",
|
||||
"Accordion.Item",
|
||||
"Accordion.Control",
|
||||
"Accordion.Panel",
|
||||
"Tabs",
|
||||
"Tabs.List",
|
||||
"Tabs.Tab",
|
||||
"Tabs.Panel",
|
||||
"Stepper.Step",
|
||||
"HoverCard",
|
||||
"HoverCard.Target",
|
||||
"HoverCard.Dropdown",
|
||||
"Menu",
|
||||
"Menu.Target",
|
||||
"Menu.Dropdown",
|
||||
"Menu.Item",
|
||||
"Menu.Label",
|
||||
"Menu.Divider",
|
||||
"Tooltip",
|
||||
"Spoiler",
|
||||
"Collapse",
|
||||
"Transition",
|
||||
]),
|
||||
...describe("@mantine/core", "navigation", "wrapped", ["Anchor", "NavLink", "Breadcrumbs", "Stepper", "Tree"]),
|
||||
...describe("@mantine/core", "interaction", "wrapped", [
|
||||
"Button",
|
||||
"ActionIcon",
|
||||
"Burger",
|
||||
"CloseButton",
|
||||
"Chip",
|
||||
"Chip.Group",
|
||||
"CopyButton",
|
||||
"Pagination",
|
||||
"Rating",
|
||||
"SegmentedControl",
|
||||
"Slider",
|
||||
"Switch",
|
||||
]),
|
||||
...describe("@mantine/charts", "charts", "allowed", [
|
||||
"AreaChart",
|
||||
"BarChart",
|
||||
"BarsList",
|
||||
"BubbleChart",
|
||||
"ChartLegend",
|
||||
"ChartTooltip",
|
||||
"CompositeChart",
|
||||
"DonutChart",
|
||||
"FunnelChart",
|
||||
"Heatmap",
|
||||
"LineChart",
|
||||
"PieChart",
|
||||
"RadarChart",
|
||||
"RadialBarChart",
|
||||
"SankeyChart",
|
||||
"ScatterChart",
|
||||
"Sparkline",
|
||||
"Treemap",
|
||||
]),
|
||||
...describe("@mantine/dates", "dates", "wrapped", [
|
||||
"Calendar",
|
||||
"MiniCalendar",
|
||||
"DatePicker",
|
||||
"Day",
|
||||
"WeekdaysRow",
|
||||
"Month",
|
||||
"CalendarHeader",
|
||||
"YearPicker",
|
||||
"MonthPicker",
|
||||
"TimeGrid",
|
||||
"PickerControl",
|
||||
"YearsList",
|
||||
"MonthsList",
|
||||
"DecadeLevel",
|
||||
"YearLevel",
|
||||
"MonthLevel",
|
||||
"LevelsGroup",
|
||||
"DecadeLevelGroup",
|
||||
"YearLevelGroup",
|
||||
"MonthLevelGroup",
|
||||
]),
|
||||
...describe("@mantine/dates", "dates", "allowed", ["TimeValue"]),
|
||||
...describe("@homarr/widgets", "interaction", "allowed", [
|
||||
"PaginatedList",
|
||||
"TabsContainer",
|
||||
"TabPanel",
|
||||
"Collapsible",
|
||||
"StatBar",
|
||||
"TypeBadge",
|
||||
]),
|
||||
...describe("@homarr/widgets", "network", "wrapped", [
|
||||
"SubFetch",
|
||||
"SubData",
|
||||
"ActionButton",
|
||||
"ToggleSwitch",
|
||||
"RefreshButton",
|
||||
]),
|
||||
{
|
||||
name: "TablerIcon",
|
||||
package: "@homarr/widgets",
|
||||
category: "content",
|
||||
safety: "wrapped",
|
||||
supportedProps: ["name", "size", "color", "stroke"],
|
||||
subcomponents: [],
|
||||
accessibilityRequirements: accessibilityByCategory.content,
|
||||
documentationUrl: documentationUrl("@homarr/widgets", "TablerIcon"),
|
||||
},
|
||||
];
|
||||
import { accessibilityByCategory, describe, documentationUrl } from "./component-descriptor";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentDescriptor,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "./component-descriptor";
|
||||
|
||||
import { customJsxDeniedComponentRegistry } from "./component-registry-denied";
|
||||
import { customJsxEnabledComponentRegistry } from "./component-registry-enabled";
|
||||
|
||||
export const customJsxComponentRegistry = [
|
||||
...customJsxEnabledComponentRegistry,
|
||||
...customJsxDeniedComponentRegistry,
|
||||
] as const;
|
||||
export const enabledCustomJsxComponents = customJsxEnabledComponentRegistry;
|
||||
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
|
||||
export const customJsxSupportedPropsByName = new Map(
|
||||
customJsxComponentRegistry.map((entry) => [entry.name, new Set(entry.supportedProps)]),
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
export type CustomJsxComponentPackage = "@mantine/core" | "@mantine/charts" | "@mantine/dates" | "@homarr/widgets";
|
||||
export type CustomJsxComponentSafety = "allowed" | "wrapped" | "denied";
|
||||
export type CustomJsxComponentCategory =
|
||||
| "layout"
|
||||
| "content"
|
||||
| "feedback"
|
||||
| "navigation"
|
||||
| "charts"
|
||||
| "dates"
|
||||
| "interaction"
|
||||
| "network"
|
||||
| "blocked";
|
||||
@@ -0,0 +1,263 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { customWidgetMethods } from "./schema-types";
|
||||
|
||||
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
|
||||
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
|
||||
|
||||
export const customJsxRequestParameterTypes = ["string", "number", "boolean"] as const;
|
||||
export type CustomJsxRequestParameterType = (typeof customJsxRequestParameterTypes)[number];
|
||||
|
||||
const requestIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_-]*$/,
|
||||
"Request IDs must start with a letter and contain only lowercase letters, numbers, - or _",
|
||||
);
|
||||
|
||||
const requestParametersSchema = z
|
||||
.record(requestIdSchema, z.enum(customJsxRequestParameterTypes))
|
||||
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
|
||||
|
||||
const reservedRequestHeaders = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
]);
|
||||
|
||||
const staticHeadersSchema = z
|
||||
.record(z.string().min(1).max(128), z.string().max(8192))
|
||||
.refine((headers) => Object.keys(headers).length <= 32, "A request can declare at most 32 static headers")
|
||||
.refine(
|
||||
(headers) =>
|
||||
Object.keys(headers).every((name) => {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
!reservedRequestHeaders.has(normalized) &&
|
||||
!normalized.startsWith("proxy-") &&
|
||||
!normalized.startsWith("sec-") &&
|
||||
!normalized.startsWith("x-forwarded-")
|
||||
);
|
||||
}),
|
||||
"Static headers cannot override authentication, cookies, routing, forwarding, proxy, or hop-by-hop headers",
|
||||
);
|
||||
|
||||
const collectBodyParameterNames = (value: unknown, result = new Set<string>()): Set<string> => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectBodyParameterNames(entry, result));
|
||||
return result;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return result;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
result.add(entries[0][1]);
|
||||
return result;
|
||||
}
|
||||
entries.forEach(([, entry]) => collectBodyParameterNames(entry, result));
|
||||
return result;
|
||||
};
|
||||
|
||||
export const customJsxRequestSchema = z
|
||||
.object({
|
||||
id: requestIdSchema,
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(customWidgetMethods),
|
||||
pathTemplate: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.startsWith("/")
|
||||
.refine((path) => !path.startsWith("//"), "Path templates must be same-origin paths")
|
||||
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
|
||||
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
|
||||
parameters: requestParametersSchema,
|
||||
bodyTemplate: z.json().optional(),
|
||||
staticHeaders: staticHeadersSchema.optional(),
|
||||
auth: z.enum(["inherit", "none"]),
|
||||
minimumBoardPermission: z.enum(["view", "modify", "full"]),
|
||||
cacheTtlSeconds: z.number().int().min(0).max(3600).optional(),
|
||||
allowViewerExecution: z.literal(true).optional(),
|
||||
})
|
||||
.superRefine((request, ctx) => {
|
||||
if (request.kind === "query" && request.method !== "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["method"],
|
||||
message: "Query requests must use GET",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "action" && request.method === "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["method"],
|
||||
message: "Action requests must use POST, PUT, PATCH, or DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "query" && request.minimumBoardPermission !== "view") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["minimumBoardPermission"],
|
||||
message: "Query requests use view permission",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && request.minimumBoardPermission !== "full") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["minimumBoardPermission"],
|
||||
message: "DELETE actions require full board permission",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.kind === "action" &&
|
||||
request.method !== "DELETE" &&
|
||||
request.minimumBoardPermission === "view" &&
|
||||
request.allowViewerExecution !== true
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["allowViewerExecution"],
|
||||
message: "Viewer actions require explicit allowViewerExecution approval",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "query" && request.bodyTemplate !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["bodyTemplate"],
|
||||
message: "GET query requests cannot define a request body",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "action" && request.cacheTtlSeconds !== undefined && request.cacheTtlSeconds !== 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cacheTtlSeconds"],
|
||||
message: "Actions cannot be cached",
|
||||
});
|
||||
}
|
||||
|
||||
const declaredParameters = new Set(Object.keys(request.parameters));
|
||||
const pathParameters = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
|
||||
for (const parameter of [...pathParameters, ...collectBodyParameterNames(request.bodyTemplate)]) {
|
||||
if (!declaredParameters.has(parameter)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [request.bodyTemplate === undefined ? "pathTemplate" : "parameters"],
|
||||
message: `Placeholder '${parameter}' is not declared in request parameters`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
|
||||
|
||||
const customJsxTemplateSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50000)
|
||||
.superRefine((template, ctx) => {
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
if (diagnostic.severity === "error") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `${diagnostic.message} (line ${diagnostic.line}, column ${diagnostic.column})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.describe(
|
||||
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct fetch and XMLHttpRequest access are unavailable; v2 templates reference named requests.",
|
||||
);
|
||||
|
||||
export const customJsxDisplayConfigV1Schema = z.object({
|
||||
type: z
|
||||
.literal("customJsx")
|
||||
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
|
||||
template: customJsxTemplateSchema,
|
||||
jsxApiVersion: z.never().optional(),
|
||||
networkScope: z.never().optional(),
|
||||
requests: z.never().optional(),
|
||||
});
|
||||
|
||||
export const customJsxDisplayConfigV2Schema = z
|
||||
.object({
|
||||
type: z.literal("customJsx"),
|
||||
jsxApiVersion: z.literal(2),
|
||||
template: customJsxTemplateSchema,
|
||||
networkScope: z.enum(customJsxNetworkScopes),
|
||||
requests: z.array(customJsxRequestSchema).max(64),
|
||||
})
|
||||
.superRefine((config, ctx) => {
|
||||
const requestsById = new Map<string, CustomJsxRequest>();
|
||||
for (const [index, request] of config.requests.entries()) {
|
||||
if (requestsById.has(request.id)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["requests", index, "id"],
|
||||
message: `Duplicate request ID: ${request.id}`,
|
||||
});
|
||||
}
|
||||
requestsById.set(request.id, request);
|
||||
}
|
||||
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
|
||||
for (const match of config.template.matchAll(networkComponentPattern)) {
|
||||
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
|
||||
const attributes = match[2] ?? "";
|
||||
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
|
||||
const requestId = idMatch?.[1] ?? idMatch?.[2];
|
||||
if (!requestId) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} must use a literal requestId`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const request = requestsById.get(requestId);
|
||||
const expectedKind = component === "SubFetch" ? "query" : "action";
|
||||
if (!request) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} references unknown request '${requestId}'`,
|
||||
});
|
||||
} else if (request.kind !== expectedKind) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (/\b(?:url|method|headers|body)\s*=/.test(attributes)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} cannot declare inline network settings`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomJsxDisplayConfigV2 = z.infer<typeof customJsxDisplayConfigV2Schema>;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { CustomJsxDisplayConfigV2 } from "./custom-jsx-schema";
|
||||
import { displayConfigSchema } from "./display-config-schema";
|
||||
import type { DisplayConfig } from "./display-config-schema";
|
||||
import {
|
||||
customWidgetAuthTypes,
|
||||
customWidgetDisplayTypes,
|
||||
customWidgetMethods,
|
||||
customWidgetSecretKinds,
|
||||
} from "./schema-types";
|
||||
import type { CustomWidgetMethod } from "./schema-types";
|
||||
|
||||
const displayTypesMatch = (displayType?: string, configType?: string): boolean => {
|
||||
if (!displayType) return true;
|
||||
if (!configType) return true;
|
||||
return displayType === configType;
|
||||
};
|
||||
|
||||
const displayTypeMatchMessage = {
|
||||
message: "displayType must match displayConfig.type",
|
||||
path: ["displayConfig", "type"],
|
||||
};
|
||||
|
||||
const isCustomJsxV2 = (config: DisplayConfig | undefined): config is CustomJsxDisplayConfigV2 =>
|
||||
config?.type === "customJsx" && "jsxApiVersion" in config && config.jsxApiVersion === 2;
|
||||
|
||||
const validateDefinitionDisplay = (
|
||||
definition: { method?: CustomWidgetMethod; displayType?: string; displayConfig?: DisplayConfig },
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (!displayTypesMatch(definition.displayType, definition.displayConfig?.type)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: displayTypeMatchMessage.message,
|
||||
path: displayTypeMatchMessage.path,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCustomJsxV2(definition.displayConfig) && definition.method !== undefined && definition.method !== "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
|
||||
path: ["method"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const baseDefinitionSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
|
||||
description: z
|
||||
.string()
|
||||
.max(512)
|
||||
.optional()
|
||||
.describe("Optional longer description of what this widget shows, shown in tooltips and management UI"),
|
||||
iconUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional URL to an icon image for the widget (e.g. https://example.com/icon.png). Shown in widget picker.",
|
||||
),
|
||||
url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
|
||||
),
|
||||
authType: z
|
||||
.enum(customWidgetAuthTypes)
|
||||
.describe(
|
||||
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter. Secrets are configured separately in the UI.",
|
||||
),
|
||||
headerName: z
|
||||
.string()
|
||||
.max(256)
|
||||
.optional()
|
||||
.describe(
|
||||
"Custom header or query parameter name for API key auth. For apiKeyHeader: the HTTP header name (default: X-API-Key). For apiKeyQuery: the query param name (default: api_key). Ignored for other auth types.",
|
||||
),
|
||||
method: z
|
||||
.enum(customWidgetMethods)
|
||||
.describe(
|
||||
"HTTP method for the API request: 'GET' (fetch data, most common), 'POST'/'PUT'/'PATCH' (with optional requestBody), 'DELETE' (destructive actions)",
|
||||
),
|
||||
requestBody: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'JSON string body sent with POST/PUT/PATCH requests. Must be valid JSON as a string (e.g. \'{"action":"restart"}\'). Omit for GET requests.',
|
||||
),
|
||||
displayType: z
|
||||
.enum(customWidgetDisplayTypes)
|
||||
.describe(
|
||||
"How to render the API response: 'singleValue' (one big number/text), 'keyValue' (labeled pairs), 'table' (rows/columns), 'statGrid' (colored stat cards), 'progressBars' (usage bars), 'statusIndicator' (health dots), 'countGrid' (simple counts), 'raw' (JSON debug view), 'actionButton' (clickable action), 'customJsx' (custom JSX template with Mantine components)",
|
||||
),
|
||||
displayConfig: displayConfigSchema,
|
||||
});
|
||||
|
||||
const secretsInputSchema = z.array(
|
||||
z.object({
|
||||
kind: z
|
||||
.enum(customWidgetSecretKinds)
|
||||
.describe(
|
||||
"Secret type: 'apiKey' (token/key for bearer/apiKey auth), 'username' (for basic auth), 'password' (for basic auth)",
|
||||
),
|
||||
value: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("The secret value. Never include in exported JSON — configure in the UI after import."),
|
||||
}),
|
||||
);
|
||||
|
||||
export const customWidgetCreateSchema = baseDefinitionSchema
|
||||
.extend({ secrets: secretsInputSchema })
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
export const customWidgetUpdateSchema = baseDefinitionSchema
|
||||
.partial()
|
||||
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
const customWidgetImportFieldsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
|
||||
description: z
|
||||
.string()
|
||||
.max(512)
|
||||
.nullish()
|
||||
.describe("Optional longer description of what this widget shows. Use null or omit if not needed."),
|
||||
iconUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.nullish()
|
||||
.describe("Optional URL to an icon image for the widget. Use null or omit if not needed."),
|
||||
authType: z
|
||||
.enum(customWidgetAuthTypes)
|
||||
.describe(
|
||||
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter",
|
||||
),
|
||||
headerName: z
|
||||
.string()
|
||||
.max(256)
|
||||
.nullish()
|
||||
.describe(
|
||||
"Custom header or query parameter name for API key auth. For apiKeyHeader: HTTP header name (default: X-API-Key). For apiKeyQuery: query param name (default: api_key).",
|
||||
),
|
||||
method: z
|
||||
.enum(customWidgetMethods)
|
||||
.describe(
|
||||
"HTTP method: 'GET' (fetch data), 'POST'/'PUT'/'PATCH' (with requestBody), 'DELETE' (destructive). Use GET unless the API requires otherwise.",
|
||||
),
|
||||
requestBody: z.string().nullish().describe("JSON string body for POST/PUT/PATCH. Use null or omit for GET requests."),
|
||||
displayType: z
|
||||
.enum(customWidgetDisplayTypes)
|
||||
.describe(
|
||||
"How to render the API response: 'singleValue', 'keyValue', 'table', 'statGrid', 'progressBars', 'statusIndicator', 'countGrid', 'raw', 'actionButton', or 'customJsx'",
|
||||
),
|
||||
displayConfig: displayConfigSchema,
|
||||
});
|
||||
|
||||
export const customWidgetImportSchema = customWidgetImportFieldsSchema
|
||||
.extend({
|
||||
$schema: z
|
||||
.enum(["homarr-custom-widget-v2", "homarr-custom-widget-v3"])
|
||||
.optional()
|
||||
.describe("Schema version identifier. New exports use 'homarr-custom-widget-v3'; v2 remains importable."),
|
||||
url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
|
||||
),
|
||||
})
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
countGridDisplayConfigSchema,
|
||||
keyValueDisplayConfigSchema,
|
||||
progressBarsDisplayConfigSchema,
|
||||
rawDisplayConfigSchema,
|
||||
singleValueDisplayConfigSchema,
|
||||
statGridDisplayConfigSchema,
|
||||
statusIndicatorDisplayConfigSchema,
|
||||
tableDisplayConfigSchema,
|
||||
} from "./built-in-display-schemas";
|
||||
import { customJsxDisplayConfigV1Schema, customJsxDisplayConfigV2Schema } from "./custom-jsx-schema";
|
||||
|
||||
const actionButtonDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("actionButton")
|
||||
.describe("Display type discriminator — must be 'actionButton' when displayType is actionButton"),
|
||||
buttonLabel: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Text shown on the action button (e.g. 'Restart Service', 'Clear Cache', 'Run Backup')"),
|
||||
buttonColor: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Mantine color name for the button (e.g. 'blue', 'red', 'green'). Default: blue"),
|
||||
confirmText: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"If set, shows a confirmation dialog with this message before executing the API request. Omit for immediate execution.",
|
||||
),
|
||||
successMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Notification message shown after successful API execution. Omit for a generic success message."),
|
||||
});
|
||||
|
||||
export const displayConfigSchema = z
|
||||
.union([
|
||||
singleValueDisplayConfigSchema,
|
||||
keyValueDisplayConfigSchema,
|
||||
tableDisplayConfigSchema,
|
||||
statGridDisplayConfigSchema,
|
||||
progressBarsDisplayConfigSchema,
|
||||
statusIndicatorDisplayConfigSchema,
|
||||
countGridDisplayConfigSchema,
|
||||
rawDisplayConfigSchema,
|
||||
actionButtonDisplayConfigSchema,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
customJsxDisplayConfigV1Schema,
|
||||
])
|
||||
.describe(
|
||||
"Configuration for how API response data is displayed. The 'type' field MUST match the top-level 'displayType' field exactly. Choose the variant that matches your displayType.",
|
||||
);
|
||||
|
||||
export type DisplayConfig = z.infer<typeof displayConfigSchema>;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { JSONPath } from "jsonpath-plus";
|
||||
|
||||
import type { DisplayConfig } from "./display-config-schema";
|
||||
import type { CustomWidgetDisplayType } from "./schema-types";
|
||||
|
||||
export type ExtractedDisplayData = Record<string, unknown>;
|
||||
|
||||
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false }) as unknown;
|
||||
|
||||
const queryRows = (json: unknown, path: string): unknown[] => {
|
||||
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
|
||||
return Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback: number): number => Number(value) || fallback;
|
||||
|
||||
export function extractDisplayData(
|
||||
json: unknown,
|
||||
_displayType: CustomWidgetDisplayType,
|
||||
config: DisplayConfig,
|
||||
): ExtractedDisplayData {
|
||||
switch (config.type) {
|
||||
case "singleValue":
|
||||
return {
|
||||
...config,
|
||||
value: query(json, config.jsonPath),
|
||||
valueSize: config.valueSize ?? "lg",
|
||||
labelPosition: config.labelPosition ?? "below",
|
||||
};
|
||||
case "keyValue":
|
||||
return {
|
||||
type: config.type,
|
||||
entries: config.mappings.map((mapping) => ({ ...mapping, value: query(json, mapping.jsonPath) })),
|
||||
layout: config.layout ?? "list",
|
||||
columns: config.columns ?? 2,
|
||||
};
|
||||
case "table": {
|
||||
const rows = queryRows(json, config.tablePath);
|
||||
return {
|
||||
type: config.type,
|
||||
columns: config.columns.map((column) => column.header),
|
||||
rows: rows.map((row) => config.columns.map((column) => query(row, column.jsonPath))),
|
||||
striped: config.striped ?? true,
|
||||
compact: config.compact ?? false,
|
||||
};
|
||||
}
|
||||
case "statGrid":
|
||||
return {
|
||||
type: config.type,
|
||||
items: config.items.map((item) => ({
|
||||
...item,
|
||||
color: item.color ?? "blue",
|
||||
value: query(json, item.jsonPath),
|
||||
})),
|
||||
columns: config.columns ?? 2,
|
||||
cardStyle: config.cardStyle ?? "filled",
|
||||
};
|
||||
case "progressBars":
|
||||
return {
|
||||
type: config.type,
|
||||
bars: config.bars.map((bar) => ({
|
||||
...bar,
|
||||
color: bar.color ?? "blue",
|
||||
value: toNumber(query(json, bar.valuePath), 0),
|
||||
max: bar.maxPath ? toNumber(query(json, bar.maxPath), 100) : undefined,
|
||||
})),
|
||||
showPercentage: config.showPercentage ?? true,
|
||||
barSize: config.barSize ?? "md",
|
||||
};
|
||||
case "statusIndicator":
|
||||
return {
|
||||
type: config.type,
|
||||
items: config.items.map((item) => {
|
||||
const value = query(json, item.jsonPath);
|
||||
return {
|
||||
label: item.label,
|
||||
value: String(value ?? "unknown"),
|
||||
isGood: item.goodValues.some((good) => String(value).toLowerCase() === good.toLowerCase()),
|
||||
};
|
||||
}),
|
||||
layout: config.layout ?? "list",
|
||||
dotSize: config.dotSize ?? "md",
|
||||
};
|
||||
case "countGrid":
|
||||
return {
|
||||
type: config.type,
|
||||
items: config.items.map((item) => ({ ...item, value: query(json, item.jsonPath) })),
|
||||
columns: config.columns ?? 2,
|
||||
valueSize: config.valueSize ?? "md",
|
||||
};
|
||||
case "raw":
|
||||
return { type: config.type, data: query(json, config.jsonPath), maxHeight: config.maxHeight ?? 300 };
|
||||
case "actionButton":
|
||||
return {
|
||||
...config,
|
||||
buttonColor: config.buttonColor ?? "blue",
|
||||
confirmText: config.confirmText ?? "",
|
||||
successMessage: config.successMessage ?? "",
|
||||
};
|
||||
case "customJsx":
|
||||
return {
|
||||
type: config.type,
|
||||
template: config.template,
|
||||
data: json,
|
||||
jsxApiVersion: "jsxApiVersion" in config ? config.jsxApiVersion : 1,
|
||||
requestCapabilities: Array.isArray(config.requests)
|
||||
? config.requests.map(({ id, kind, method, minimumBoardPermission }) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
minimumBoardPermission,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const extractDisplayDataWithFallback = extractDisplayData;
|
||||
|
||||
export function extractActionButtonDisplay(config: DisplayConfig): ExtractedDisplayData {
|
||||
if (config.type !== "actionButton") throw new Error("Display configuration is not an action button");
|
||||
return extractDisplayData(null, "actionButton", config);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { customJsxRequestSchema, displayConfigSchema } from "./schema";
|
||||
import type { DisplayConfig } from "./schema";
|
||||
import type { CustomWidgetDisplayType } from "./schema";
|
||||
|
||||
function hydrateDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as typeof base.columns) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template:
|
||||
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...base, ...typeOverrides[displayType]?.() };
|
||||
}
|
||||
const starterExample = customJsxExamples.find((example) => example.id === "simple-metric");
|
||||
if (!starterExample) throw new Error("The simple-metric Custom JSX fixture is required");
|
||||
export const CUSTOM_JSX_STARTER = starterExample.template;
|
||||
|
||||
export type CustomWidgetDisplayFormValues = ReturnType<typeof hydrateDisplayFormValues>;
|
||||
|
||||
const displayConfigBuilders: Record<string, (values: CustomWidgetDisplayFormValues) => Record<string, unknown>> = {
|
||||
singleValue: (values) => ({
|
||||
type: "singleValue",
|
||||
jsonPath: values.jsonPath,
|
||||
label: values.label,
|
||||
unit: values.unit,
|
||||
valueSize: values.valueSize,
|
||||
labelPosition: values.labelPosition,
|
||||
}),
|
||||
keyValue: (values) => ({
|
||||
type: "keyValue",
|
||||
mappings: values.mappings,
|
||||
layout: values.kvLayout,
|
||||
columns: values.kvColumns,
|
||||
}),
|
||||
table: (values) => ({
|
||||
type: "table",
|
||||
tablePath: values.tablePath,
|
||||
columns: values.columns,
|
||||
striped: values.striped,
|
||||
compact: values.compact,
|
||||
}),
|
||||
statGrid: (values) => ({
|
||||
type: "statGrid",
|
||||
items: values.statGridItems,
|
||||
columns: values.statGridColumns,
|
||||
cardStyle: values.cardStyle,
|
||||
}),
|
||||
progressBars: (values) => ({
|
||||
type: "progressBars",
|
||||
bars: values.progressBars.map((bar) => ({ ...bar, maxPath: bar.maxPath || undefined })),
|
||||
showPercentage: values.showPercentage,
|
||||
barSize: values.barSize,
|
||||
}),
|
||||
statusIndicator: (values) => ({
|
||||
type: "statusIndicator",
|
||||
items: values.statusItems.map((item) => ({
|
||||
...item,
|
||||
goodValues: item.goodValues
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
})),
|
||||
layout: values.statusLayout,
|
||||
dotSize: values.dotSize,
|
||||
}),
|
||||
countGrid: (values) => ({
|
||||
type: "countGrid",
|
||||
items: values.countGridItems,
|
||||
columns: values.countGridColumns,
|
||||
valueSize: values.countValueSize,
|
||||
}),
|
||||
raw: (values) => ({ type: "raw", jsonPath: values.rawJsonPath, maxHeight: values.rawMaxHeight }),
|
||||
actionButton: (values) => ({
|
||||
type: "actionButton",
|
||||
buttonLabel: values.buttonLabel,
|
||||
buttonColor: values.buttonColor,
|
||||
confirmText: values.confirmText || undefined,
|
||||
successMessage: values.successMessage || undefined,
|
||||
}),
|
||||
customJsx: (values) =>
|
||||
values.jsxApiVersion === "2"
|
||||
? {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: values.template.trim() || CUSTOM_JSX_STARTER,
|
||||
networkScope: values.networkScope,
|
||||
requests: parseRequestManifest(values.requestManifest),
|
||||
}
|
||||
: { type: "customJsx", template: values.template.trim() || CUSTOM_JSX_STARTER },
|
||||
};
|
||||
|
||||
export type CustomWidgetDisplayIcon =
|
||||
| "action"
|
||||
| "code"
|
||||
| "count"
|
||||
| "grid"
|
||||
| "json"
|
||||
| "keyValue"
|
||||
| "number"
|
||||
| "progress"
|
||||
| "status"
|
||||
| "table";
|
||||
|
||||
export interface CustomWidgetDisplayDescriptor {
|
||||
type: CustomWidgetDisplayType;
|
||||
icon: CustomWidgetDisplayIcon;
|
||||
supportsActions: boolean;
|
||||
supportsNamedRequests: boolean;
|
||||
defaults(): CustomWidgetDisplayFormValues;
|
||||
hydrate(config: Record<string, unknown>): CustomWidgetDisplayFormValues;
|
||||
serialize(values: CustomWidgetDisplayFormValues): DisplayConfig;
|
||||
validate(config: unknown): DisplayConfig;
|
||||
}
|
||||
|
||||
const descriptorMetadata = [
|
||||
["singleValue", "number", false, false],
|
||||
["keyValue", "keyValue", false, false],
|
||||
["table", "table", false, false],
|
||||
["statGrid", "grid", false, false],
|
||||
["progressBars", "progress", false, false],
|
||||
["statusIndicator", "status", false, false],
|
||||
["countGrid", "count", false, false],
|
||||
["raw", "json", false, false],
|
||||
["actionButton", "action", true, false],
|
||||
["customJsx", "code", true, true],
|
||||
] as const satisfies readonly [CustomWidgetDisplayType, CustomWidgetDisplayIcon, boolean, boolean][];
|
||||
|
||||
export const customWidgetDisplayDescriptors: readonly CustomWidgetDisplayDescriptor[] = descriptorMetadata.map(
|
||||
([type, icon, supportsActions, supportsNamedRequests]) => ({
|
||||
type,
|
||||
icon,
|
||||
supportsActions,
|
||||
supportsNamedRequests,
|
||||
defaults: () => hydrateDisplayFormValues(type, {}),
|
||||
hydrate: (config) => hydrateDisplayFormValues(type, config),
|
||||
serialize: (values) => {
|
||||
const build = displayConfigBuilders[type];
|
||||
if (!build) throw new Error(`Unsupported custom-widget display type: ${type}`);
|
||||
return displayConfigSchema.parse(build(values));
|
||||
},
|
||||
validate: (config) => displayConfigSchema.parse(config),
|
||||
}),
|
||||
);
|
||||
|
||||
export function getCustomWidgetDisplayDescriptor(type: string): CustomWidgetDisplayDescriptor {
|
||||
const descriptor = customWidgetDisplayDescriptors.find((candidate) => candidate.type === type);
|
||||
if (!descriptor) throw new Error(`Unsupported custom-widget display type: ${type}`);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
return getCustomWidgetDisplayDescriptor(displayType).hydrate(config);
|
||||
}
|
||||
|
||||
export function buildDisplayConfigFromFormValues(values: CustomWidgetDisplayFormValues): DisplayConfig {
|
||||
return getCustomWidgetDisplayDescriptor(values.displayType).serialize(values);
|
||||
}
|
||||
|
||||
function parseRequestManifest(value: string) {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return customJsxRequestSchema.array().parse(parsed);
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,84 @@
|
||||
export interface ImportReview {
|
||||
name: string;
|
||||
origin: string;
|
||||
authType: string;
|
||||
networkScope: string;
|
||||
methods: string[];
|
||||
permissions: string[];
|
||||
hasActions: boolean;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/iu)?.[1];
|
||||
const jsxBlock = text.match(/```(?:jsx|tsx)\s*([\s\S]*?)```/iu)?.[1]?.trim();
|
||||
const value: unknown = JSON.parse(jsonBlock ?? text);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const widget = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof widget.name !== "string" ||
|
||||
typeof widget.url !== "string" ||
|
||||
typeof widget.displayType !== "string" ||
|
||||
!widget.displayConfig ||
|
||||
typeof widget.displayConfig !== "object" ||
|
||||
Array.isArray(widget.displayConfig)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (jsxBlock && widget.displayType === "customJsx") {
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
|
||||
displayConfig.template = jsxBlock;
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
|
||||
if (!value) return null;
|
||||
const displayConfig = value.displayConfig as Record<string, unknown>;
|
||||
const requests = Array.isArray(displayConfig.requests)
|
||||
? displayConfig.requests.filter(
|
||||
(request): request is Record<string, unknown> =>
|
||||
request !== null && typeof request === "object" && !Array.isArray(request),
|
||||
)
|
||||
: [];
|
||||
const baseMethod = typeof value.method === "string" ? value.method : "GET";
|
||||
const methods = [
|
||||
...new Set([
|
||||
baseMethod,
|
||||
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
|
||||
]),
|
||||
];
|
||||
const permissions = [
|
||||
...new Set(
|
||||
requests
|
||||
.map((request) => request.minimumBoardPermission)
|
||||
.filter(
|
||||
(permission): permission is string =>
|
||||
permission === "view" || permission === "modify" || permission === "full",
|
||||
),
|
||||
),
|
||||
];
|
||||
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
|
||||
|
||||
let origin = value.url;
|
||||
try {
|
||||
origin = new URL(value.url as string).origin;
|
||||
} catch {
|
||||
// The server returns the localized validation error after confirmation.
|
||||
}
|
||||
|
||||
return {
|
||||
name: value.name as string,
|
||||
origin: String(origin),
|
||||
authType: typeof value.authType === "string" ? value.authType : "none",
|
||||
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
|
||||
methods,
|
||||
permissions,
|
||||
hasActions: methods.some((method) => method !== "GET"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./ai-prompt";
|
||||
export * from "./component-registry";
|
||||
export * from "./display-data";
|
||||
export * from "./display-form";
|
||||
export * from "./examples";
|
||||
export * from "./import";
|
||||
export * from "./schema";
|
||||
@@ -0,0 +1,22 @@
|
||||
export const customWidgetAuthTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
|
||||
export type CustomWidgetAuthType = (typeof customWidgetAuthTypes)[number];
|
||||
|
||||
export const customWidgetDisplayTypes = [
|
||||
"singleValue",
|
||||
"keyValue",
|
||||
"table",
|
||||
"statGrid",
|
||||
"progressBars",
|
||||
"statusIndicator",
|
||||
"countGrid",
|
||||
"raw",
|
||||
"actionButton",
|
||||
"customJsx",
|
||||
] as const;
|
||||
export type CustomWidgetDisplayType = (typeof customWidgetDisplayTypes)[number];
|
||||
|
||||
export const customWidgetMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
export type CustomWidgetMethod = (typeof customWidgetMethods)[number];
|
||||
|
||||
export const customWidgetSecretKinds = ["apiKey", "username", "password"] as const;
|
||||
export type CustomWidgetSecretKind = (typeof customWidgetSecretKinds)[number];
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./built-in-display-schemas";
|
||||
export * from "./custom-jsx-schema";
|
||||
export * from "./definition-schemas";
|
||||
export * from "./display-config-schema";
|
||||
export * from "./schema-types";
|
||||
+43
-43
@@ -1,7 +1,13 @@
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import { customJsxComponentByName, customJsxSupportedPropsByName } from "@homarr/definitions";
|
||||
import { customJsxComponentByName, customJsxSupportedPropsByName } from "../core/component-registry";
|
||||
import {
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES,
|
||||
CUSTOM_JSX_BLOCKED_PROPS,
|
||||
CUSTOM_JSX_LIMITS,
|
||||
normalizeCustomJsxProperty,
|
||||
} from "./policy";
|
||||
|
||||
interface AstNode {
|
||||
type: string;
|
||||
@@ -19,8 +25,6 @@ export interface CustomJsxTemplateDiagnostic {
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
const MAX_AST_DEPTH = 64;
|
||||
const MAX_OPERATIONS = 25_000;
|
||||
|
||||
const rootBindings = new Set([
|
||||
"Array",
|
||||
@@ -43,34 +47,6 @@ const rootBindings = new Set([
|
||||
"undefined",
|
||||
]);
|
||||
|
||||
const blockedProperties = new Set([
|
||||
"__proto__",
|
||||
"apply",
|
||||
"arguments",
|
||||
"bind",
|
||||
"call",
|
||||
"callee",
|
||||
"caller",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
const blockedProps = new Set([
|
||||
"children",
|
||||
"className",
|
||||
"classNames",
|
||||
"classes",
|
||||
"component",
|
||||
"dangerouslySetInnerHTML",
|
||||
"innerRef",
|
||||
"portalProps",
|
||||
"ref",
|
||||
"renderRoot",
|
||||
"styles",
|
||||
"unstyled",
|
||||
"withinPortal",
|
||||
]);
|
||||
|
||||
const nodeOf = (value: unknown): AstNode | null =>
|
||||
value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
|
||||
? (value as AstNode)
|
||||
@@ -79,10 +55,23 @@ const nodeOf = (value: unknown): AstNode | null =>
|
||||
const nodesOf = (value: unknown): AstNode[] =>
|
||||
Array.isArray(value) ? value.map(nodeOf).filter((node): node is AstNode => node !== null) : [];
|
||||
|
||||
const normalizedProperty = (value: unknown): string =>
|
||||
String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase();
|
||||
const staticPropertyName = (node: AstNode | null): string | undefined => {
|
||||
if (!node) return undefined;
|
||||
if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number")) {
|
||||
return String(node.value);
|
||||
}
|
||||
if (node.type === "BinaryExpression" && node.operator === "+") {
|
||||
const left = staticPropertyName(nodeOf(node.left));
|
||||
const right = staticPropertyName(nodeOf(node.right));
|
||||
return left === undefined || right === undefined ? undefined : left + right;
|
||||
}
|
||||
if (node.type === "TemplateLiteral" && Array.isArray(node.expressions) && node.expressions.length === 0) {
|
||||
return (node.quasis as Array<{ value?: { cooked?: unknown } }> | undefined)
|
||||
?.map((quasi) => String(quasi.value?.cooked ?? ""))
|
||||
.join("");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
|
||||
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
|
||||
@@ -101,11 +90,12 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
|
||||
const checkBudget = (node: AstNode, depth: number) => {
|
||||
operations += 1;
|
||||
if (depth > MAX_AST_DEPTH) add(node, `Template exceeds the AST depth limit (${MAX_AST_DEPTH})`);
|
||||
if (operations === MAX_OPERATIONS + 1) {
|
||||
add(node, `Template exceeds the operation limit (${MAX_OPERATIONS})`);
|
||||
if (depth > CUSTOM_JSX_LIMITS.astDepth)
|
||||
add(node, `Template exceeds the AST depth limit (${CUSTOM_JSX_LIMITS.astDepth})`);
|
||||
if (operations === CUSTOM_JSX_LIMITS.operations + 1) {
|
||||
add(node, `Template exceeds the operation limit (${CUSTOM_JSX_LIMITS.operations})`);
|
||||
}
|
||||
return depth <= MAX_AST_DEPTH && operations <= MAX_OPERATIONS;
|
||||
return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations;
|
||||
};
|
||||
|
||||
const tagName = (node: AstNode): string | null => {
|
||||
@@ -159,7 +149,10 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
const attributeNameNode = nodeOf(attribute.name);
|
||||
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
|
||||
const supportedProps = name ? customJsxSupportedPropsByName.get(name) : undefined;
|
||||
if ((/^on/i.test(attributeName) && !supportedProps?.has(attributeName)) || blockedProps.has(attributeName)) {
|
||||
if (
|
||||
(/^on/i.test(attributeName) && !supportedProps?.has(attributeName)) ||
|
||||
CUSTOM_JSX_BLOCKED_PROPS.has(attributeName)
|
||||
) {
|
||||
add(attribute, `Prop '${attributeName}' is not allowed`);
|
||||
} else if (name && !supportedProps?.has(attributeName)) {
|
||||
add(attribute, `Prop '${attributeName}' is not supported by ${name} and will be ignored`, "warning");
|
||||
@@ -211,7 +204,11 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
}
|
||||
const key = nodeOf(property.key);
|
||||
if (property.computed && key) visit(key, depth + 1, bindings);
|
||||
if (!property.computed && key && blockedProperties.has(normalizedProperty(key.name ?? key.value))) {
|
||||
if (
|
||||
!property.computed &&
|
||||
key &&
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(key.name ?? key.value))
|
||||
) {
|
||||
add(key, "Reflective object properties are not allowed");
|
||||
}
|
||||
const value = nodeOf(property.value);
|
||||
@@ -243,8 +240,11 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
const property = nodeOf(node.property);
|
||||
if (object) visit(object, depth + 1, bindings);
|
||||
if (node.computed && property) visit(property, depth + 1, bindings);
|
||||
if (property && blockedProperties.has(normalizedProperty(property.name ?? property.value))) {
|
||||
add(property, "Reflective property access is not allowed");
|
||||
const propertyName = node.computed
|
||||
? staticPropertyName(property)
|
||||
: String(property?.name ?? property?.value ?? "");
|
||||
if (propertyName && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(propertyName))) {
|
||||
add(property ?? node, "Reflective property access is not allowed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createSafeCallable } from "./safe-bindings";
|
||||
|
||||
function sanitizeData(value: unknown): unknown {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(sanitizeData);
|
||||
const safe: Record<string, unknown> = Object.create(null);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
|
||||
safe[key] = sanitizeData(child);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
const safeMath = Object.freeze(
|
||||
Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
round: createSafeCallable((value: number) => Math.round(value)),
|
||||
floor: createSafeCallable((value: number) => Math.floor(value)),
|
||||
ceil: createSafeCallable((value: number) => Math.ceil(value)),
|
||||
abs: createSafeCallable((value: number) => Math.abs(value)),
|
||||
min: createSafeCallable((...values: number[]) => Math.min(...values)),
|
||||
max: createSafeCallable((...values: number[]) => Math.max(...values)),
|
||||
pow: createSafeCallable((base: number, exponent: number) => Math.pow(base, exponent)),
|
||||
sqrt: createSafeCallable((value: number) => Math.sqrt(value)),
|
||||
PI: Math.PI,
|
||||
}),
|
||||
);
|
||||
|
||||
const safeDate = Object.freeze(
|
||||
Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
now: createSafeCallable(() => Date.now()),
|
||||
create: createSafeCallable((value?: string | number) =>
|
||||
value === undefined ? Date.now() : new Date(value).getTime(),
|
||||
),
|
||||
toISOString: createSafeCallable((value: string | number) => new Date(value).toISOString()),
|
||||
toLocaleDateString: createSafeCallable((value: string | number, locale?: string) =>
|
||||
new Date(value).toLocaleDateString(locale ?? "en-US"),
|
||||
),
|
||||
toLocaleTimeString: createSafeCallable((value: string | number, locale?: string) =>
|
||||
new Date(value).toLocaleTimeString(locale ?? "en-US"),
|
||||
),
|
||||
getTime: createSafeCallable((value: string | number) => new Date(value).getTime()),
|
||||
getYear: createSafeCallable((value: string | number) => new Date(value).getFullYear()),
|
||||
getMonth: createSafeCallable((value: string | number) => new Date(value).getMonth()),
|
||||
getDay: createSafeCallable((value: string | number) => new Date(value).getUTCDay()),
|
||||
}),
|
||||
);
|
||||
|
||||
export function createCustomJsxBindings(apiData: unknown) {
|
||||
const safeJson = Object.freeze(
|
||||
Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
stringify: createSafeCallable((value: unknown) => JSON.stringify(value)),
|
||||
}),
|
||||
);
|
||||
const safeArray = Object.freeze(
|
||||
Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
isArray: createSafeCallable(Array.isArray),
|
||||
from: createSafeCallable((value: ArrayLike<unknown> | Iterable<unknown>) => Array.from(value).slice(0, 2_000)),
|
||||
}),
|
||||
);
|
||||
const safeObject = Object.freeze(
|
||||
Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
keys: createSafeCallable(Object.keys),
|
||||
values: createSafeCallable(Object.values),
|
||||
entries: createSafeCallable(Object.entries),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
data: sanitizeData(apiData),
|
||||
String: createSafeCallable((value: unknown) => String(value)),
|
||||
Number: createSafeCallable((value: unknown) => Number(value)),
|
||||
Boolean: createSafeCallable((value: unknown) => Boolean(value)),
|
||||
Math: safeMath,
|
||||
JSON: safeJson,
|
||||
Array: safeArray,
|
||||
Object: safeObject,
|
||||
Date: safeDate,
|
||||
parseInt: createSafeCallable((value: string, radix?: number) => parseInt(value, radix)),
|
||||
parseFloat: createSafeCallable((value: string) => parseFloat(value)),
|
||||
encodeURIComponent: createSafeCallable((value: string) => encodeURIComponent(value)),
|
||||
decodeURIComponent: createSafeCallable((value: string) => decodeURIComponent(value)),
|
||||
isNaN: createSafeCallable((value: unknown) => Number.isNaN(Number(value))),
|
||||
isFinite: createSafeCallable((value: unknown) => Number.isFinite(Number(value))),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { isSafeCallable } from "./safe-bindings";
|
||||
import { isInterpreterCallback, normalizedSliceIndex, numericArgument, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { Budget, InterpreterCallback } from "./interpreter-foundation";
|
||||
|
||||
interface CollectionMethodContext {
|
||||
budget: Budget;
|
||||
runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown;
|
||||
}
|
||||
|
||||
export function callCollectionMethod(
|
||||
receiver: unknown,
|
||||
method: string,
|
||||
args: unknown[],
|
||||
depth: number,
|
||||
context: CollectionMethodContext,
|
||||
): { handled: boolean; value?: unknown } {
|
||||
if (Array.isArray(receiver)) {
|
||||
if (method === "map" || method === "filter") {
|
||||
const callback = args[0];
|
||||
if (isSafeCallable(callback)) {
|
||||
const result: unknown[] = [];
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
context.budget.collection();
|
||||
const callbackResult = callback(receiver[index] as never);
|
||||
if (method === "map") result.push(callbackResult);
|
||||
else if (callbackResult) result.push(receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError(`${method} requires an inline arrow callback`);
|
||||
const result: unknown[] = [];
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
context.budget.collection();
|
||||
const callbackResult = context.runCallback(callback, [receiver[index], index, receiver], depth + 1);
|
||||
if (method === "map" || callbackResult) result.push(method === "map" ? callbackResult : receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "pop") {
|
||||
context.budget.collection();
|
||||
return { handled: true, value: receiver.length > 0 ? receiver[receiver.length - 1] : undefined };
|
||||
}
|
||||
if (method === "slice") {
|
||||
const start = normalizedSliceIndex(args[0], receiver.length, 0);
|
||||
const end = normalizedSliceIndex(args[1], receiver.length, receiver.length);
|
||||
const result: unknown[] = [];
|
||||
for (let index = start; index < Math.max(start, end); index += 1) {
|
||||
context.budget.collection();
|
||||
result.push(receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "join") {
|
||||
context.budget.collection(receiver.length);
|
||||
const separator = String(args[0] ?? ",");
|
||||
let result = "";
|
||||
receiver.forEach((item, index) => {
|
||||
result += `${index === 0 ? "" : separator}${String(item ?? "")}`;
|
||||
context.budget.string(result);
|
||||
});
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "includes") {
|
||||
context.budget.collection(receiver.length);
|
||||
return { handled: true, value: receiver.some((item) => Object.is(item, args[0])) };
|
||||
}
|
||||
if (method === "indexOf") {
|
||||
context.budget.collection(receiver.length);
|
||||
return { handled: true, value: receiver.findIndex((item) => Object.is(item, args[0])) };
|
||||
}
|
||||
if (method === "find" || method === "findIndex" || method === "some" || method === "every") {
|
||||
const callback = args[0];
|
||||
const useSafe = isSafeCallable(callback);
|
||||
if (!useSafe && !isInterpreterCallback(callback))
|
||||
throw new SafeJsxError(`${method} requires an inline arrow callback`);
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
context.budget.collection();
|
||||
const result = useSafe
|
||||
? (callback as (...a: never[]) => unknown)(receiver[index] as never)
|
||||
: context.runCallback(callback as InterpreterCallback, [receiver[index], index, receiver], depth + 1);
|
||||
if (method === "find" && result) return { handled: true, value: receiver[index] };
|
||||
if (method === "findIndex" && result) return { handled: true, value: index };
|
||||
if (method === "some" && result) return { handled: true, value: true };
|
||||
if (method === "every" && !result) return { handled: true, value: false };
|
||||
}
|
||||
const defaults: Record<string, unknown> = { find: undefined, findIndex: -1, some: false, every: true };
|
||||
return { handled: true, value: defaults[method] };
|
||||
}
|
||||
if (method === "sort") {
|
||||
const callback = args[0];
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError("sort requires an inline arrow callback");
|
||||
const copy = [...receiver];
|
||||
copy.sort((left, right) => {
|
||||
context.budget.collection();
|
||||
const delta = Number(context.runCallback(callback, [left, right], depth + 1));
|
||||
if (!Number.isFinite(delta)) return 0;
|
||||
return delta;
|
||||
});
|
||||
return { handled: true, value: copy };
|
||||
}
|
||||
if (method === "reduce") {
|
||||
const callback = args[0];
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError("reduce requires an inline arrow callback");
|
||||
if (receiver.length === 0 && args.length < 2) {
|
||||
throw new SafeJsxError("Reduce of empty array with no initial value");
|
||||
}
|
||||
let startIndex = 0;
|
||||
let accumulator: unknown;
|
||||
if (args.length < 2) {
|
||||
accumulator = receiver[0];
|
||||
startIndex = 1;
|
||||
} else {
|
||||
accumulator = args[1];
|
||||
}
|
||||
for (let index = startIndex; index < receiver.length; index += 1) {
|
||||
context.budget.collection();
|
||||
accumulator = context.runCallback(callback, [accumulator, receiver[index], index, receiver], depth + 1);
|
||||
}
|
||||
return { handled: true, value: accumulator };
|
||||
}
|
||||
if (method === "at") {
|
||||
const index = numericArgument(args[0], 0);
|
||||
let normalizedIndex = index;
|
||||
if (index < 0) normalizedIndex = receiver.length + index;
|
||||
context.budget.collection();
|
||||
const outOfBounds = normalizedIndex < 0 || normalizedIndex >= receiver.length;
|
||||
return { handled: true, value: outOfBounds ? undefined : receiver[normalizedIndex] };
|
||||
}
|
||||
if (method === "flat") {
|
||||
// ponytail: only depth=1 supported; deeper nesting requires recursive budget tracking
|
||||
const flatDepth = numericArgument(args[0], 1);
|
||||
if (flatDepth !== 1) throw new SafeJsxError("flat() only supports depth 1");
|
||||
const result: unknown[] = [];
|
||||
for (const item of receiver) {
|
||||
context.budget.collection();
|
||||
if (Array.isArray(item)) {
|
||||
for (const nested of item) {
|
||||
context.budget.collection();
|
||||
result.push(nested);
|
||||
}
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "reverse") {
|
||||
const copy = [...receiver];
|
||||
const result: unknown[] = [];
|
||||
for (let index = copy.length - 1; index >= 0; index -= 1) {
|
||||
context.budget.collection();
|
||||
result.push(copy[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof receiver === "string") {
|
||||
switch (method) {
|
||||
case "toUpperCase":
|
||||
return { handled: true, value: context.budget.string(receiver.toUpperCase()) };
|
||||
case "toLowerCase":
|
||||
return { handled: true, value: context.budget.string(receiver.toLowerCase()) };
|
||||
case "trim":
|
||||
return { handled: true, value: context.budget.string(receiver.trim()) };
|
||||
case "trimStart":
|
||||
return { handled: true, value: context.budget.string(receiver.trimStart()) };
|
||||
case "trimEnd":
|
||||
return { handled: true, value: context.budget.string(receiver.trimEnd()) };
|
||||
case "replace": {
|
||||
const search = String(args[0] ?? "");
|
||||
const replacement = String(args[1] ?? "");
|
||||
const matchIndex = receiver.indexOf(search);
|
||||
if (matchIndex === -1) return { handled: true, value: context.budget.string(receiver) };
|
||||
return {
|
||||
handled: true,
|
||||
value: context.budget.string(
|
||||
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
|
||||
),
|
||||
};
|
||||
}
|
||||
case "replaceAll": {
|
||||
const search = String(args[0] ?? "");
|
||||
const replacement = String(args[1] ?? "");
|
||||
if (search.length === 0) return { handled: true, value: context.budget.string(receiver) };
|
||||
let result = "";
|
||||
let cursor = 0;
|
||||
while (cursor < receiver.length) {
|
||||
const matchIndex = receiver.indexOf(search, cursor);
|
||||
if (matchIndex === -1) {
|
||||
result += receiver.slice(cursor);
|
||||
break;
|
||||
}
|
||||
result += receiver.slice(cursor, matchIndex) + replacement;
|
||||
cursor = matchIndex + search.length;
|
||||
}
|
||||
return { handled: true, value: context.budget.string(result) };
|
||||
}
|
||||
case "indexOf":
|
||||
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "lastIndexOf": {
|
||||
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
|
||||
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
|
||||
}
|
||||
case "repeat": {
|
||||
let maxCount = 0;
|
||||
if (receiver.length > 0) maxCount = Math.floor(200_001 / receiver.length);
|
||||
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
|
||||
return { handled: true, value: context.budget.string(receiver.repeat(count).slice(0, 200_001)) };
|
||||
}
|
||||
case "slice":
|
||||
return {
|
||||
handled: true,
|
||||
value: context.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
|
||||
};
|
||||
case "substring":
|
||||
return {
|
||||
handled: true,
|
||||
value: context.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
|
||||
};
|
||||
case "includes":
|
||||
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "startsWith":
|
||||
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "endsWith":
|
||||
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
|
||||
case "padStart":
|
||||
return {
|
||||
handled: true,
|
||||
value: context.budget.string(
|
||||
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
|
||||
),
|
||||
};
|
||||
case "padEnd":
|
||||
return {
|
||||
handled: true,
|
||||
value: context.budget.string(
|
||||
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
|
||||
),
|
||||
};
|
||||
case "charAt":
|
||||
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
|
||||
case "split": {
|
||||
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000)));
|
||||
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
|
||||
context.budget.collection(split.length);
|
||||
return { handled: true, value: split };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof receiver === "number") {
|
||||
if (method === "toFixed") {
|
||||
const digits = Math.max(0, Math.min(20, numericArgument(args[0], 0)));
|
||||
return { handled: true, value: receiver.toFixed(digits) };
|
||||
}
|
||||
if (method === "toPrecision") {
|
||||
const precision = Math.max(1, Math.min(21, numericArgument(args[0], 1)));
|
||||
return { handled: true, value: receiver.toPrecision(precision) };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof receiver === "number" || typeof receiver === "boolean" || typeof receiver === "string") &&
|
||||
method === "toString"
|
||||
) {
|
||||
return { handled: true, value: context.budget.string(String(receiver)) };
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./analyzer";
|
||||
export * from "./interpreter";
|
||||
export * from "./policy";
|
||||
export * from "./safe-bindings";
|
||||
export * from "./bindings";
|
||||
export * from "./runtime-components";
|
||||
export * from "./interactive-components";
|
||||
export { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
File renamed without changes.
@@ -0,0 +1,108 @@
|
||||
import { CUSTOM_JSX_LIMITS } from "./policy";
|
||||
|
||||
export interface AstNode {
|
||||
type: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EvaluationBudgets {
|
||||
maxAstDepth: number;
|
||||
maxOperations: number;
|
||||
maxCollectionItems: number;
|
||||
maxRenderedNodes: number;
|
||||
maxStringLength: number;
|
||||
}
|
||||
|
||||
export interface SafeJsxBudgets extends Partial<EvaluationBudgets> {}
|
||||
export const INTERPRETER_CALLBACK = Symbol("custom-jsx-interpreter-callback");
|
||||
export const DEFAULT_BUDGETS: EvaluationBudgets = {
|
||||
maxAstDepth: CUSTOM_JSX_LIMITS.astDepth,
|
||||
maxOperations: CUSTOM_JSX_LIMITS.operations,
|
||||
maxCollectionItems: CUSTOM_JSX_LIMITS.collectionItems,
|
||||
maxRenderedNodes: CUSTOM_JSX_LIMITS.renderedNodes,
|
||||
maxStringLength: CUSTOM_JSX_LIMITS.stringLength,
|
||||
};
|
||||
|
||||
export interface InterpreterCallback {
|
||||
readonly kind: typeof INTERPRETER_CALLBACK;
|
||||
readonly params: readonly string[];
|
||||
readonly body: AstNode;
|
||||
readonly environment: Environment;
|
||||
}
|
||||
|
||||
export class SafeJsxError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SafeJsxError";
|
||||
}
|
||||
}
|
||||
|
||||
export class Environment {
|
||||
public constructor(
|
||||
private readonly values: Readonly<Record<string, unknown>>,
|
||||
private readonly parent?: Environment,
|
||||
) {}
|
||||
public get(name: string): unknown {
|
||||
if (Object.hasOwn(this.values, name)) return this.values[name];
|
||||
if (this.parent) return this.parent.get(name);
|
||||
if (name === "undefined") return undefined;
|
||||
if (name === "NaN") return Number.NaN;
|
||||
if (name === "Infinity") return Number.POSITIVE_INFINITY;
|
||||
throw new SafeJsxError(`Unknown binding: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class Budget {
|
||||
private operations = 0;
|
||||
private collectionItems = 0;
|
||||
private renderedNodes = 0;
|
||||
public constructor(private readonly limits: EvaluationBudgets) {}
|
||||
public operation(depth: number): void {
|
||||
if (depth > this.limits.maxAstDepth)
|
||||
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
|
||||
if (++this.operations > this.limits.maxOperations)
|
||||
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
|
||||
}
|
||||
public collection(count = 1): void {
|
||||
this.collectionItems += count;
|
||||
if (this.collectionItems > this.limits.maxCollectionItems)
|
||||
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
|
||||
}
|
||||
public rendered(): void {
|
||||
if (++this.renderedNodes > this.limits.maxRenderedNodes)
|
||||
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
|
||||
}
|
||||
public string(value: string): string {
|
||||
if (value.length > this.limits.maxStringLength)
|
||||
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function asNode(value: unknown, context: string): AstNode {
|
||||
if (!value || typeof value !== "object" || typeof (value as { type?: unknown }).type !== "string")
|
||||
throw new SafeJsxError(`Invalid ${context} node`);
|
||||
return value as AstNode;
|
||||
}
|
||||
|
||||
export function asNodeArray(value: unknown, context: string): AstNode[] {
|
||||
if (!Array.isArray(value)) throw new SafeJsxError(`Invalid ${context}`);
|
||||
return value.map((item) => asNode(item, context));
|
||||
}
|
||||
|
||||
export const isInterpreterCallback = (value: unknown): value is InterpreterCallback =>
|
||||
typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === INTERPRETER_CALLBACK;
|
||||
|
||||
export function numericArgument(value: unknown, fallback: number): number {
|
||||
const number = value === undefined ? fallback : Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.trunc(number);
|
||||
}
|
||||
|
||||
export function normalizedSliceIndex(value: unknown, length: number, fallback: number): number {
|
||||
const index = numericArgument(value, fallback);
|
||||
if (index < 0) return Math.max(length + index, 0);
|
||||
return Math.min(index, length);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import { callCollectionMethod } from "./collection-methods";
|
||||
import { emitJsxElement, emitJsxFragment } from "./jsx-emitter";
|
||||
import type { JsxEmitterContext } from "./jsx-emitter";
|
||||
import { isSafeCallable } from "./safe-bindings";
|
||||
import {
|
||||
asNode,
|
||||
asNodeArray,
|
||||
Budget,
|
||||
DEFAULT_BUDGETS,
|
||||
Environment,
|
||||
INTERPRETER_CALLBACK,
|
||||
SafeJsxError,
|
||||
} from "./interpreter-foundation";
|
||||
import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets } from "./interpreter-foundation";
|
||||
import { normalizedProperty, ownProperty } from "./safe-properties";
|
||||
import { CUSTOM_JSX_LIMITS } from "./policy";
|
||||
|
||||
export { SafeJsxError } from "./interpreter-foundation";
|
||||
export type { SafeJsxBudgets } from "./interpreter-foundation";
|
||||
export { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
export interface RenderSafeJsxOptions {
|
||||
template: string;
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
bindings: Readonly<Record<string, unknown>>;
|
||||
budgets?: SafeJsxBudgets;
|
||||
}
|
||||
|
||||
export interface SafeJsxRenderResult {
|
||||
node: ReactNode;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
|
||||
function jsxText(value: string): string {
|
||||
const lines = value.replace(/\r/g, "").split("\n");
|
||||
let result = "";
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let line = lines[index] ?? "";
|
||||
line = line.replace(/\t/g, " ");
|
||||
if (index !== 0) line = line.replace(/^\s+/, "");
|
||||
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
|
||||
if (!line) continue;
|
||||
if (result) result += " ";
|
||||
result += line;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class Interpreter {
|
||||
private readonly budget: Budget;
|
||||
private readonly warnings = new Set<string>();
|
||||
|
||||
public constructor(
|
||||
private readonly components: Readonly<Record<string, ComponentType<never>>>,
|
||||
private readonly environment: Environment,
|
||||
private readonly limits: EvaluationBudgets,
|
||||
) {
|
||||
this.budget = new Budget(limits);
|
||||
}
|
||||
|
||||
public render(root: AstNode): SafeJsxRenderResult {
|
||||
return { node: this.evaluate(root, this.environment, 0) as ReactNode, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
private evaluate(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
this.budget.operation(depth);
|
||||
|
||||
switch (node.type) {
|
||||
case "Literal":
|
||||
return node.value;
|
||||
case "Identifier":
|
||||
return environment.get(String(node.name));
|
||||
case "JSXElement":
|
||||
return this.evaluateJsxElement(node, environment, depth + 1);
|
||||
case "JSXFragment":
|
||||
return this.evaluateJsxFragment(node, environment, depth + 1);
|
||||
case "JSXText": {
|
||||
const text = this.budget.string(jsxText(String(node.value ?? "")));
|
||||
if (text) this.budget.rendered();
|
||||
return text || null;
|
||||
}
|
||||
case "JSXExpressionContainer": {
|
||||
const expression = asNode(node.expression, "JSX expression");
|
||||
if (expression.type === "JSXEmptyExpression") return null;
|
||||
const value = this.evaluate(expression, environment, depth + 1);
|
||||
return typeof value === "string" ? this.budget.string(value) : value;
|
||||
}
|
||||
case "ArrayExpression":
|
||||
return this.evaluateArray(node, environment, depth + 1);
|
||||
case "ObjectExpression":
|
||||
return this.evaluateObject(node, environment, depth + 1);
|
||||
case "UnaryExpression":
|
||||
return this.evaluateUnary(node, environment, depth + 1);
|
||||
case "BinaryExpression":
|
||||
return this.evaluateBinary(node, environment, depth + 1);
|
||||
case "LogicalExpression":
|
||||
return this.evaluateLogical(node, environment, depth + 1);
|
||||
case "ConditionalExpression":
|
||||
return this.evaluate(node.test as AstNode, environment, depth + 1)
|
||||
? this.evaluate(asNode(node.consequent, "conditional consequent"), environment, depth + 1)
|
||||
: this.evaluate(asNode(node.alternate, "conditional alternate"), environment, depth + 1);
|
||||
case "MemberExpression":
|
||||
return this.evaluateMember(node, environment, depth + 1);
|
||||
case "CallExpression":
|
||||
return this.evaluateCall(node, environment, depth + 1);
|
||||
case "ArrowFunctionExpression":
|
||||
return this.createCallback(node, environment);
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node, environment, depth + 1);
|
||||
case "ChainExpression":
|
||||
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateJsxFragment(node: AstNode, environment: Environment, depth: number): ReactNode {
|
||||
return emitJsxFragment(node, environment, depth, this.emitterContext());
|
||||
}
|
||||
|
||||
private evaluateJsxElement(node: AstNode, environment: Environment, depth: number): ReactNode {
|
||||
return emitJsxElement(node, environment, depth, this.emitterContext());
|
||||
}
|
||||
|
||||
private emitterContext(): JsxEmitterContext {
|
||||
return {
|
||||
components: this.components,
|
||||
budget: this.budget,
|
||||
warnings: this.warnings,
|
||||
evaluate: (node, environment, depth) => this.evaluate(node, environment, depth),
|
||||
renderCallback: (callback, args) =>
|
||||
new Interpreter(this.components, callback.environment, this.limits).runCallback(callback, args, 0) as ReactNode,
|
||||
};
|
||||
}
|
||||
|
||||
private evaluateArray(node: AstNode, environment: Environment, depth: number): unknown[] {
|
||||
const result: unknown[] = [];
|
||||
const elements = node.elements;
|
||||
if (!Array.isArray(elements)) throw new SafeJsxError("Invalid array expression");
|
||||
for (const elementValue of elements) {
|
||||
if (elementValue == null) {
|
||||
result.push(undefined);
|
||||
continue;
|
||||
}
|
||||
const element = asNode(elementValue, "array element");
|
||||
if (element.type === "SpreadElement") {
|
||||
const spread = this.evaluate(asNode(element.argument, "array spread"), environment, depth + 1);
|
||||
if (!Array.isArray(spread)) throw new SafeJsxError("Only arrays can be spread into arrays");
|
||||
this.budget.collection(spread.length);
|
||||
result.push(...spread);
|
||||
} else {
|
||||
this.budget.collection();
|
||||
result.push(this.evaluate(element, environment, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateObject(node: AstNode, environment: Environment, depth: number): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = Object.create(null);
|
||||
for (const property of asNodeArray(node.properties, "object properties")) {
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = this.evaluate(asNode(property.argument, "object spread"), environment, depth + 1);
|
||||
if (!spread || typeof spread !== "object" || Array.isArray(spread)) {
|
||||
throw new SafeJsxError("Only objects can be spread into objects");
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) result[normalizedProperty(key)] = value;
|
||||
continue;
|
||||
}
|
||||
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
|
||||
throw new SafeJsxError("Only explicit object properties are supported");
|
||||
}
|
||||
const keyNode = asNode(property.key, "object key");
|
||||
const key = normalizedProperty(
|
||||
property.computed
|
||||
? this.evaluate(keyNode, environment, depth + 1)
|
||||
: keyNode.type === "Identifier"
|
||||
? keyNode.name
|
||||
: keyNode.value,
|
||||
);
|
||||
result[key] = this.evaluate(asNode(property.value, "object value"), environment, depth + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateUnary(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const value = this.evaluate(asNode(node.argument, "unary argument"), environment, depth + 1);
|
||||
switch (node.operator) {
|
||||
case "!":
|
||||
return !value;
|
||||
case "+":
|
||||
return Number(value);
|
||||
case "-":
|
||||
return -Number(value);
|
||||
case "typeof":
|
||||
return typeof value;
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported unary operator: ${String(node.operator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateBinary(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const left = this.evaluate(asNode(node.left, "binary left operand"), environment, depth + 1);
|
||||
const right = this.evaluate(asNode(node.right, "binary right operand"), environment, depth + 1);
|
||||
switch (node.operator) {
|
||||
case "+":
|
||||
return typeof left === "string" || typeof right === "string"
|
||||
? this.budget.string(String(left) + String(right))
|
||||
: Number(left) + Number(right);
|
||||
case "-":
|
||||
return Number(left) - Number(right);
|
||||
case "*":
|
||||
return Number(left) * Number(right);
|
||||
case "/":
|
||||
return Number(left) / Number(right);
|
||||
case "%":
|
||||
return Number(left) % Number(right);
|
||||
case "**":
|
||||
return Number(left) ** Number(right);
|
||||
case "<":
|
||||
return (left as number) < (right as number);
|
||||
case "<=":
|
||||
return (left as number) <= (right as number);
|
||||
case ">":
|
||||
return (left as number) > (right as number);
|
||||
case ">=":
|
||||
return (left as number) >= (right as number);
|
||||
case "==":
|
||||
return String(left) === String(right);
|
||||
case "!=":
|
||||
return String(left) !== String(right);
|
||||
case "===":
|
||||
return left === right;
|
||||
case "!==":
|
||||
return left !== right;
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported binary operator: ${String(node.operator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateLogical(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const left = this.evaluate(asNode(node.left, "logical left operand"), environment, depth + 1);
|
||||
if (node.operator === "&&") {
|
||||
return left ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
|
||||
}
|
||||
if (node.operator === "||") {
|
||||
return left ? left : this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1);
|
||||
}
|
||||
if (node.operator === "??") {
|
||||
return left == null ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
|
||||
}
|
||||
throw new SafeJsxError(`Unsupported logical operator: ${String(node.operator)}`);
|
||||
}
|
||||
|
||||
private memberProperty(node: AstNode, environment: Environment, depth: number): string {
|
||||
const propertyNode = asNode(node.property, "member property");
|
||||
return normalizedProperty(
|
||||
node.computed
|
||||
? this.evaluate(propertyNode, environment, depth + 1)
|
||||
: propertyNode.type === "Identifier"
|
||||
? propertyNode.name
|
||||
: propertyNode.value,
|
||||
);
|
||||
}
|
||||
|
||||
private evaluateMember(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const object = this.evaluate(asNode(node.object, "member object"), environment, depth + 1);
|
||||
const property = this.memberProperty(node, environment, depth + 1);
|
||||
return ownProperty(object, property);
|
||||
}
|
||||
|
||||
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
|
||||
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
|
||||
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
|
||||
return this.evaluate(argument, environment, depth + 1);
|
||||
});
|
||||
const callee = asNode(node.callee, "call target");
|
||||
|
||||
if (callee.type === "MemberExpression") {
|
||||
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
|
||||
const method = this.memberProperty(callee, environment, depth + 1);
|
||||
const collectionResult = this.callCollectionMethod(receiver, method, args, depth + 1);
|
||||
if (collectionResult.handled) return collectionResult.value;
|
||||
const callable = ownProperty(receiver, method);
|
||||
if (!isSafeCallable(callable)) throw new SafeJsxError(`Calling method '${method}' is not allowed`);
|
||||
return this.accountHostResult(callable(...(args as never[])));
|
||||
}
|
||||
|
||||
const callable = this.evaluate(callee, environment, depth + 1);
|
||||
if (!isSafeCallable(callable)) throw new SafeJsxError("Calling this value is not allowed");
|
||||
return this.accountHostResult(callable(...(args as never[])));
|
||||
}
|
||||
|
||||
private callCollectionMethod(
|
||||
receiver: unknown,
|
||||
method: string,
|
||||
args: unknown[],
|
||||
depth: number,
|
||||
): { handled: boolean; value?: unknown } {
|
||||
return callCollectionMethod(receiver, method, args, depth, {
|
||||
budget: this.budget,
|
||||
runCallback: (callback, callbackArgs, callbackDepth) => this.runCallback(callback, callbackArgs, callbackDepth),
|
||||
});
|
||||
}
|
||||
|
||||
private accountHostResult(value: unknown): unknown {
|
||||
if (typeof value === "string") return this.budget.string(value);
|
||||
if (Array.isArray(value)) this.budget.collection(value.length);
|
||||
return value;
|
||||
}
|
||||
|
||||
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
|
||||
}
|
||||
const params = asNodeArray(node.params, "callback parameters").map((param) => {
|
||||
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
|
||||
return normalizedProperty(param.name);
|
||||
});
|
||||
return {
|
||||
kind: INTERPRETER_CALLBACK,
|
||||
params,
|
||||
body: asNode(node.body, "callback body"),
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
|
||||
const values: Record<string, unknown> = Object.create(null);
|
||||
callback.params.forEach((name, index) => {
|
||||
values[name] = args[index];
|
||||
});
|
||||
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
|
||||
const quasis = asNodeArray(node.quasis, "template literal parts");
|
||||
const expressions = asNodeArray(node.expressions, "template literal expressions");
|
||||
let result = "";
|
||||
quasis.forEach((quasi, index) => {
|
||||
const value = quasi.value;
|
||||
if (!value || typeof value !== "object") throw new SafeJsxError("Invalid template literal part");
|
||||
result += String((value as { cooked?: unknown }).cooked ?? "");
|
||||
const expression = expressions[index];
|
||||
if (expression) result += String(this.evaluate(expression, environment, depth + 1));
|
||||
});
|
||||
return this.budget.string(result);
|
||||
}
|
||||
}
|
||||
|
||||
function parseTemplate(template: string): AstNode {
|
||||
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
|
||||
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
|
||||
}
|
||||
let program: AstNode;
|
||||
try {
|
||||
program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowAwaitOutsideFunction: false,
|
||||
allowReturnOutsideFunction: false,
|
||||
}) as unknown as AstNode;
|
||||
} catch (error) {
|
||||
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
|
||||
}
|
||||
const body = asNodeArray(program.body, "program body");
|
||||
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
|
||||
throw new SafeJsxError("Template must contain JSX only");
|
||||
}
|
||||
return asNode(body[0].expression, "template expression");
|
||||
}
|
||||
|
||||
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
|
||||
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
|
||||
}
|
||||
const root = parseTemplate(template);
|
||||
return new Interpreter(components, new Environment(bindings), limits).render(root);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement, Fragment } from "react";
|
||||
|
||||
import { asNode, asNodeArray, isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
|
||||
import { normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
|
||||
import { CUSTOM_JSX_BLOCKED_TAGS } from "./policy";
|
||||
|
||||
export interface JsxEmitterContext {
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
budget: Budget;
|
||||
warnings: Set<string>;
|
||||
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
|
||||
renderCallback(callback: InterpreterCallback, args: unknown[]): ReactNode;
|
||||
}
|
||||
|
||||
export function emitJsxFragment(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
depth: number,
|
||||
context: JsxEmitterContext,
|
||||
): ReactNode {
|
||||
const children = asNodeArray(node.children, "fragment children").map((child) =>
|
||||
context.evaluate(child, environment, depth + 1),
|
||||
);
|
||||
context.budget.rendered();
|
||||
return createElement(Fragment, null, ...(children as ReactNode[]));
|
||||
}
|
||||
|
||||
export function emitJsxElement(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
depth: number,
|
||||
context: JsxEmitterContext,
|
||||
): ReactNode {
|
||||
const opening = asNode(node.openingElement, "JSX opening element");
|
||||
const tag = jsxTagName(asNode(opening.name, "JSX tag"));
|
||||
if (CUSTOM_JSX_BLOCKED_TAGS.has(tag.toLowerCase())) {
|
||||
context.warnings.add(`Blocked element: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
const component = context.components[tag];
|
||||
if (!component) {
|
||||
context.warnings.add(`Unknown or unavailable component: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
const rawProps: Record<string, unknown> = {};
|
||||
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const spread = context.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
|
||||
if (spread && typeof spread === "object" && !Array.isArray(spread)) Object.assign(rawProps, spread);
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
|
||||
const nameNode = asNode(attribute.name, "attribute name");
|
||||
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
|
||||
const name = normalizedProperty(nameNode.name);
|
||||
if (attribute.value == null) rawProps[name] = true;
|
||||
else {
|
||||
const value = asNode(attribute.value, "attribute value");
|
||||
rawProps[name] = value.type === "Literal" ? value.value : context.evaluate(value, environment, depth + 1);
|
||||
}
|
||||
}
|
||||
let children = asNodeArray(node.children, "JSX children").map((child) =>
|
||||
context.evaluate(child, environment, depth + 1),
|
||||
);
|
||||
const props = sanitizeCustomJsxProps(rawProps, tag);
|
||||
if (tag === "SubFetch") {
|
||||
const callbackChildren = children.filter(isInterpreterCallback);
|
||||
const callbackProp = rawProps.render;
|
||||
if (callbackProp !== undefined && !isInterpreterCallback(callbackProp)) {
|
||||
throw new SafeJsxError("SubFetch render must be an inline arrow callback");
|
||||
}
|
||||
if (callbackChildren.length > 1 || (callbackChildren.length === 1 && callbackProp !== undefined)) {
|
||||
throw new SafeJsxError("SubFetch accepts only one render callback");
|
||||
}
|
||||
const callback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
|
||||
if (callback) {
|
||||
props.render = (value: unknown, metadata: unknown) => context.renderCallback(callback, [value, metadata]);
|
||||
children = children.filter((child) => child !== callback);
|
||||
}
|
||||
}
|
||||
context.budget.rendered();
|
||||
return createElement(component, props as never, ...(children as ReactNode[]));
|
||||
}
|
||||
|
||||
function jsxTagName(node: AstNode): string {
|
||||
if (node.type === "JSXIdentifier") return String(node.name);
|
||||
if (node.type === "JSXMemberExpression") {
|
||||
return `${jsxTagName(asNode(node.object, "JSX member object"))}.${jsxTagName(asNode(node.property, "JSX member property"))}`;
|
||||
}
|
||||
throw new SafeJsxError(`Unsupported JSX tag: ${node.type}`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export const CUSTOM_JSX_LIMITS = Object.freeze({
|
||||
templateLength: 50_000,
|
||||
astDepth: 64,
|
||||
operations: 25_000,
|
||||
collectionItems: 2_000,
|
||||
renderedNodes: 10_000,
|
||||
stringLength: 200_000,
|
||||
});
|
||||
|
||||
export const CUSTOM_JSX_BLOCKED_PROPERTIES: ReadonlySet<string> = new Set([
|
||||
"__proto__",
|
||||
"apply",
|
||||
"arguments",
|
||||
"bind",
|
||||
"call",
|
||||
"callee",
|
||||
"caller",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
export const CUSTOM_JSX_BLOCKED_PROPS: ReadonlySet<string> = new Set([
|
||||
"children",
|
||||
"className",
|
||||
"classNames",
|
||||
"classes",
|
||||
"component",
|
||||
"dangerouslySetInnerHTML",
|
||||
"innerRef",
|
||||
"portalProps",
|
||||
"ref",
|
||||
"renderRoot",
|
||||
"styles",
|
||||
"unstyled",
|
||||
"withinPortal",
|
||||
]);
|
||||
|
||||
export const CUSTOM_JSX_BLOCKED_TAGS: ReadonlySet<string> = new Set([
|
||||
"base",
|
||||
"embed",
|
||||
"form",
|
||||
"iframe",
|
||||
"link",
|
||||
"meta",
|
||||
"object",
|
||||
"script",
|
||||
"style",
|
||||
]);
|
||||
|
||||
export const CUSTOM_JSX_URL_PROPS: ReadonlySet<string> = new Set(["backgroundImage", "href", "src"]);
|
||||
|
||||
export const CUSTOM_JSX_BLOCKED_STYLE_KEYS: ReadonlySet<string> = new Set([
|
||||
"backdropFilter",
|
||||
"behavior",
|
||||
"bottom",
|
||||
"clipPath",
|
||||
"content",
|
||||
"filter",
|
||||
"inset",
|
||||
"left",
|
||||
"mask",
|
||||
"pointerEvents",
|
||||
"position",
|
||||
"right",
|
||||
"top",
|
||||
"zIndex",
|
||||
]);
|
||||
|
||||
export function normalizeCustomJsxProperty(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement, useEffect, useRef, useState } from "react";
|
||||
import * as Core from "@mantine/core";
|
||||
import * as Charts from "@mantine/charts";
|
||||
import * as Dates from "@mantine/dates";
|
||||
|
||||
import { enabledCustomJsxComponents } from "../core/component-registry";
|
||||
import { ActionButton, ToggleSwitch } from "../runtime/actions";
|
||||
import { SubData } from "../runtime/data";
|
||||
import { RefreshButton } from "../runtime/refresh-button";
|
||||
import { SubFetch } from "../runtime/sub-fetch";
|
||||
import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge } from "./interactive-components";
|
||||
import { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
type Namespace = Readonly<Record<string, unknown>>;
|
||||
|
||||
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
|
||||
let value: unknown = namespace;
|
||||
for (const segment of name.split(".")) {
|
||||
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
|
||||
return undefined;
|
||||
}
|
||||
value = (value as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof value === "function" || (typeof value === "object" && value !== null)
|
||||
? (value as ComponentType<never>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeUrl(value: unknown) {
|
||||
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
|
||||
}
|
||||
|
||||
function wrap(component: ComponentType<never>, additions: Record<string, unknown> = {}) {
|
||||
return function SafeComponent(props: Record<string, unknown>) {
|
||||
return createElement(component, { ...sanitizeCustomJsxProps(props), ...additions } as never);
|
||||
};
|
||||
}
|
||||
|
||||
function SafeLink({ component, props }: { component: ComponentType<never>; props: Record<string, unknown> }) {
|
||||
const sanitized = sanitizeCustomJsxProps(props);
|
||||
return createElement(component, {
|
||||
...sanitized,
|
||||
href: safeUrl(props.href) ? props.href : undefined,
|
||||
rel: sanitized.target === "_blank" ? "noopener noreferrer" : sanitized.rel,
|
||||
} as never);
|
||||
}
|
||||
|
||||
function createCopyButton(labels: { copy: string; copied: string }) {
|
||||
return function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
const copy = async () => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 2_000);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Core.Button
|
||||
size="xs"
|
||||
variant={copied ? "filled" : "light"}
|
||||
color={copied ? "teal" : "blue"}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{copied ? labels.copied : labels.copy}
|
||||
</Core.Button>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomJsxComponentAdapters {
|
||||
TablerIcon: ComponentType<never>;
|
||||
copyLabels: { copy: string; copied: string };
|
||||
}
|
||||
|
||||
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
|
||||
const namespaces: Record<string, Namespace> = {
|
||||
"@mantine/core": Core as unknown as Namespace,
|
||||
"@mantine/charts": Charts as unknown as Namespace,
|
||||
"@mantine/dates": Dates as unknown as Namespace,
|
||||
};
|
||||
const components: Record<string, ComponentType<never>> = {};
|
||||
for (const descriptor of enabledCustomJsxComponents) {
|
||||
const namespace = namespaces[descriptor.package];
|
||||
const component = namespace ? resolveExport(namespace, descriptor.name) : undefined;
|
||||
if (component) components[descriptor.name] = component;
|
||||
}
|
||||
const core = Core as unknown as Namespace;
|
||||
for (const name of [
|
||||
"Notification",
|
||||
"LoadingOverlay",
|
||||
"Overlay",
|
||||
"Breadcrumbs",
|
||||
"Stepper",
|
||||
"Tree",
|
||||
"Button",
|
||||
"ActionIcon",
|
||||
"Burger",
|
||||
"CloseButton",
|
||||
"Chip",
|
||||
] as const) {
|
||||
const component = resolveExport(core, name);
|
||||
if (component) components[name] = wrap(component);
|
||||
}
|
||||
const anchor = resolveExport(core, "Anchor");
|
||||
const navLink = resolveExport(core, "NavLink");
|
||||
if (anchor) components.Anchor = (props) => <SafeLink component={anchor} props={props} />;
|
||||
if (navLink) components.NavLink = (props) => <SafeLink component={navLink} props={props} />;
|
||||
for (const [name, additions] of Object.entries({
|
||||
Rating: { readOnly: true },
|
||||
Slider: { readOnly: true },
|
||||
Switch: { readOnly: true },
|
||||
SegmentedControl: { readOnly: true },
|
||||
Pagination: { disabled: true },
|
||||
})) {
|
||||
const component = resolveExport(core, name);
|
||||
if (component) components[name] = wrap(component, additions);
|
||||
}
|
||||
const dates = Dates as unknown as Namespace;
|
||||
for (const descriptor of enabledCustomJsxComponents.filter(
|
||||
(item) => item.package === "@mantine/dates" && item.safety === "wrapped",
|
||||
)) {
|
||||
const component = resolveExport(dates, descriptor.name);
|
||||
if (component) components[descriptor.name] = wrap(component, { static: true });
|
||||
}
|
||||
Object.assign(components, {
|
||||
CopyButton: createCopyButton(adapters.copyLabels),
|
||||
PaginatedList,
|
||||
TabsContainer,
|
||||
TabPanel,
|
||||
Collapsible,
|
||||
StatBar,
|
||||
TypeBadge,
|
||||
TablerIcon: adapters.TablerIcon,
|
||||
SubFetch,
|
||||
SubData,
|
||||
ActionButton,
|
||||
ToggleSwitch,
|
||||
RefreshButton,
|
||||
});
|
||||
return components;
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,96 @@
|
||||
import { customJsxSupportedPropsByName } from "../core";
|
||||
import { isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
|
||||
import {
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES,
|
||||
CUSTOM_JSX_BLOCKED_PROPS,
|
||||
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
|
||||
CUSTOM_JSX_URL_PROPS,
|
||||
normalizeCustomJsxProperty,
|
||||
} from "./policy";
|
||||
|
||||
export function normalizedProperty(value: unknown): string {
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
throw new SafeJsxError("Property names must be strings or numbers");
|
||||
}
|
||||
const property = String(value);
|
||||
if (CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(property))) {
|
||||
throw new SafeJsxError(`Access to reflective property '${property}' is not allowed`);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
function isArrayIndex(property: string): boolean {
|
||||
if (!/^(?:0|[1-9]\d*)$/.test(property)) return false;
|
||||
const index = Number(property);
|
||||
return Number.isSafeInteger(index) && index >= 0;
|
||||
}
|
||||
|
||||
export function ownProperty(object: unknown, propertyValue: unknown): unknown {
|
||||
const property = normalizedProperty(propertyValue);
|
||||
if (object == null) return undefined;
|
||||
|
||||
if (Array.isArray(object)) {
|
||||
if (property === "length") return object.length;
|
||||
if (!isArrayIndex(property)) return undefined;
|
||||
return Object.hasOwn(object, property) ? object[Number(property)] : undefined;
|
||||
}
|
||||
|
||||
if (typeof object === "string") {
|
||||
if (property === "length") return object.length;
|
||||
if (!isArrayIndex(property)) return undefined;
|
||||
return object[Number(property)];
|
||||
}
|
||||
|
||||
if (typeof object !== "object") return undefined;
|
||||
return Object.hasOwn(object, property) ? (object as Record<string, unknown>)[property] : undefined;
|
||||
}
|
||||
|
||||
function isSafeUrl(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
if (value.startsWith("#")) return true;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStyle(value: unknown): Record<string, string | number> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const result: Record<string, string | number> = {};
|
||||
for (const [key, styleValue] of Object.entries(value)) {
|
||||
if (CUSTOM_JSX_BLOCKED_STYLE_KEYS.has(key) || key.startsWith("--")) continue;
|
||||
if (typeof styleValue !== "string" && typeof styleValue !== "number") continue;
|
||||
if (typeof styleValue === "string" && /(?:url\s*\(|expression\s*\(|javascript:|position\s*:)/i.test(styleValue)) {
|
||||
continue;
|
||||
}
|
||||
result[key] = styleValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeCustomJsxProps(
|
||||
props: Readonly<Record<string, unknown>>,
|
||||
componentName?: string,
|
||||
): Record<string, unknown> {
|
||||
const safe: Record<string, unknown> = {};
|
||||
const supportedProps = componentName ? customJsxSupportedPropsByName.get(componentName) : undefined;
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if ((/^on/i.test(key) && !supportedProps?.has(key)) || CUSTOM_JSX_BLOCKED_PROPS.has(key)) continue;
|
||||
if (componentName && !supportedProps?.has(key)) continue;
|
||||
if (typeof value === "function" || isInterpreterCallback(value)) continue;
|
||||
if (CUSTOM_JSX_URL_PROPS.has(key)) {
|
||||
if (isSafeUrl(value)) safe[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "style") {
|
||||
const style = sanitizeStyle(value);
|
||||
if (style) safe.style = style;
|
||||
continue;
|
||||
}
|
||||
safe[key] = value;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Switch } from "@mantine/core";
|
||||
import { IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
import { normalizeParams } from "./data";
|
||||
import { MigrationRequiredAlert } from "./status";
|
||||
import type { CustomJsxRuntimeParams, CustomWidgetRequestResult } from "./types";
|
||||
|
||||
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
|
||||
play: IconPlayerPlay,
|
||||
check: IconCheck,
|
||||
refresh: IconRefresh,
|
||||
power: IconPower,
|
||||
trash: IconTrash,
|
||||
};
|
||||
|
||||
function parseBool(value: unknown, fallback = false) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function useActionExecutor() {
|
||||
const runtime = useCustomWidgetRuntime();
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({
|
||||
requestId,
|
||||
params,
|
||||
confirmed,
|
||||
}: {
|
||||
requestId: string;
|
||||
params: CustomJsxRuntimeParams;
|
||||
confirmed: boolean;
|
||||
}): Promise<CustomWidgetRequestResult> => {
|
||||
if (!runtime.itemId && !runtime.previewSessionId) {
|
||||
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.widgetItemUnavailable });
|
||||
}
|
||||
if (runtime.isEditMode) {
|
||||
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.actionsDisabledEditMode });
|
||||
}
|
||||
return runtime.port.executeAction({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
requestId,
|
||||
params,
|
||||
confirmed,
|
||||
});
|
||||
},
|
||||
});
|
||||
return { runtime, mutation };
|
||||
}
|
||||
|
||||
export interface ActionButtonProps {
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
label: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
confirmMessage?: string;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
icon?: string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: string;
|
||||
fullWidth?: boolean | string;
|
||||
}
|
||||
|
||||
export function ActionButton(props: ActionButtonProps) {
|
||||
const { runtime, mutation } = useActionExecutor();
|
||||
const params = normalizeParams(props.params);
|
||||
const Icon = props.icon ? ICON_MAP[props.icon.toLowerCase()] : undefined;
|
||||
const mustConfirm = runtime.requestCapabilities.some(
|
||||
(capability) => capability.id === props.requestId && capability.kind === "action" && capability.method === "DELETE",
|
||||
);
|
||||
const run = async (confirmed: boolean) => {
|
||||
if (!props.requestId || !params) return;
|
||||
try {
|
||||
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed });
|
||||
const success = result.ok;
|
||||
runtime.port.notify({
|
||||
kind: success ? "success" : "error",
|
||||
title: props.label,
|
||||
message: success
|
||||
? (props.successMessage ??
|
||||
(result.simulated ? runtime.messages.actionSimulated : runtime.messages.actionCompleted))
|
||||
: (props.errorMessage ?? result.error ?? `${runtime.messages.requestFailed} (${result.status})`),
|
||||
});
|
||||
if (success)
|
||||
await runtime.port.invalidate({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
targets: props.invalidate ?? [],
|
||||
});
|
||||
} catch {
|
||||
runtime.port.notify({
|
||||
kind: "error",
|
||||
title: props.label,
|
||||
message: props.errorMessage ?? runtime.messages.requestFailed,
|
||||
});
|
||||
}
|
||||
};
|
||||
const click = async () => {
|
||||
const confirmMessage = props.confirmMessage ?? (mustConfirm ? runtime.messages.confirmDelete : undefined);
|
||||
if (confirmMessage && !(await runtime.port.confirm({ title: props.label, message: confirmMessage }))) return;
|
||||
await run(Boolean(confirmMessage));
|
||||
};
|
||||
if (!props.requestId) return <MigrationRequiredAlert />;
|
||||
return (
|
||||
<Button
|
||||
color={props.color ?? "blue"}
|
||||
variant={(props.variant ?? "filled") as never}
|
||||
size={(props.size ?? "sm") as never}
|
||||
onClick={() => void click()}
|
||||
loading={mutation.isPending}
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
disabled={
|
||||
parseBool(props.disabled) || runtime.isEditMode || (!runtime.itemId && !runtime.previewSessionId) || !params
|
||||
}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
requestId?: string;
|
||||
onParams?: CustomJsxRuntimeParams;
|
||||
offParams?: CustomJsxRuntimeParams;
|
||||
initialValue?: boolean | string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
errorMessage?: string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
onBody?: string;
|
||||
offBody?: string;
|
||||
}
|
||||
|
||||
export function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
const { runtime, mutation } = useActionExecutor();
|
||||
const initial = parseBool(props.initialValue);
|
||||
const [checked, setChecked] = useState(initial);
|
||||
const locked = useRef(false);
|
||||
const onParams = normalizeParams(props.onParams);
|
||||
const offParams = normalizeParams(props.offParams);
|
||||
useEffect(() => {
|
||||
if (!locked.current) setChecked(initial);
|
||||
}, [initial]);
|
||||
const change = async (next: boolean) => {
|
||||
const params = next ? onParams : offParams;
|
||||
if (!props.requestId || !params || locked.current) return;
|
||||
locked.current = true;
|
||||
const previous = checked;
|
||||
setChecked(next);
|
||||
try {
|
||||
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed: false });
|
||||
if (!result.ok) {
|
||||
setChecked(previous);
|
||||
runtime.port.notify({
|
||||
kind: "error",
|
||||
title: props.label ?? runtime.messages.toggle,
|
||||
message: props.errorMessage ?? result.error ?? runtime.messages.requestFailed,
|
||||
});
|
||||
} else {
|
||||
await runtime.port.invalidate({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
targets: props.invalidate ?? [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setChecked(previous);
|
||||
runtime.port.notify({
|
||||
kind: "error",
|
||||
title: props.label ?? runtime.messages.toggle,
|
||||
message: props.errorMessage ?? runtime.messages.requestFailed,
|
||||
});
|
||||
} finally {
|
||||
locked.current = false;
|
||||
}
|
||||
};
|
||||
if (!props.requestId) return <MigrationRequiredAlert />;
|
||||
return (
|
||||
<Switch
|
||||
label={props.label}
|
||||
color={props.color ?? "blue"}
|
||||
size={(props.size ?? "sm") as never}
|
||||
checked={checked}
|
||||
onChange={(event) => void change(event.currentTarget.checked)}
|
||||
disabled={
|
||||
parseBool(props.disabled) ||
|
||||
mutation.isPending ||
|
||||
runtime.isEditMode ||
|
||||
(!runtime.itemId && !runtime.previewSessionId) ||
|
||||
!onParams ||
|
||||
!offParams
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ActionIcon, Code, Group, ScrollArea, SimpleGrid, Stack, Table, Text, Tooltip } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import type { CustomWidgetDisplayData } from "./display-types";
|
||||
|
||||
export function KeyValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }> | undefined) ?? [];
|
||||
const content = entries.map((entry, index) => (
|
||||
<Group key={index} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{String(entry.value ?? "—")}
|
||||
{entry.unit ? ` ${entry.unit}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
));
|
||||
return data.layout === "grid" ? (
|
||||
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
|
||||
{content}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{content}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const columns = (data.columns as string[] | undefined) ?? [];
|
||||
const rows = (data.rows as unknown[][] | undefined) ?? [];
|
||||
return (
|
||||
<ScrollArea>
|
||||
<Table striped={data.striped !== false} highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map((column, index) => (
|
||||
<Table.Th key={index} py={data.compact ? 4 : undefined}>
|
||||
{column}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<Table.Tr key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<Table.Td key={cellIndex} py={data.compact ? 2 : undefined}>
|
||||
{String(cell ?? "—")}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusIndicatorDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }> | undefined) ?? [];
|
||||
const size = ({ sm: 8, md: 10, lg: 14 } as Record<string, number>)[String(data.dotSize ?? "md")] ?? 10;
|
||||
const content = items.map((item, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ml="auto">
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
));
|
||||
return data.layout === "grid" ? (
|
||||
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
|
||||
{content}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{content}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function openJsonInBrowser(value: unknown) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }));
|
||||
window.open(url);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
export function createRawDisplay(openJsonLabel: string) {
|
||||
return function RawDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
return (
|
||||
<Stack gap={4} p="xs">
|
||||
<Group justify="flex-end">
|
||||
<Tooltip label={openJsonLabel}>
|
||||
<ActionIcon
|
||||
aria-label={openJsonLabel}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => openJsonInBrowser(data.data)}
|
||||
>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea mah={Number(data.maxHeight ?? 300)}>
|
||||
<Code block style={{ fontSize: 12 }}>
|
||||
{JSON.stringify(data.data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { CustomWidgetRuntimeValue } from "./types";
|
||||
|
||||
const RuntimeContext = createContext<CustomWidgetRuntimeValue | null>(null);
|
||||
|
||||
export function CustomWidgetRuntimeProvider({
|
||||
children,
|
||||
...value
|
||||
}: CustomWidgetRuntimeValue & { children: ReactNode }) {
|
||||
return <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCustomWidgetRuntime(): CustomWidgetRuntimeValue {
|
||||
const value = useContext(RuntimeContext);
|
||||
if (!value) throw new Error("Custom Widget runtime components require CustomWidgetRuntimeProvider");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useWidgetDefinitionId(): string {
|
||||
return useCustomWidgetRuntime().definitionId ?? "";
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ComponentType, ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { renderSafeJsx } from "../jsx/interpreter";
|
||||
import type { CustomJsxRequestCapability } from "./types";
|
||||
|
||||
const methodColors: Readonly<Record<string, string>> = {
|
||||
GET: "blue",
|
||||
POST: "orange",
|
||||
PUT: "yellow",
|
||||
DELETE: "red",
|
||||
PATCH: "grape",
|
||||
};
|
||||
const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
const kinds = new Set(["query", "action"]);
|
||||
const permissions = new Set(["view", "modify", "full"]);
|
||||
|
||||
export interface CustomJsxRendererMessages {
|
||||
noTemplate: string;
|
||||
interactive: string;
|
||||
networkCapabilities: string;
|
||||
templateWarnings(count: number): string;
|
||||
}
|
||||
|
||||
export interface CustomJsxRendererProps {
|
||||
template: string;
|
||||
data: unknown;
|
||||
requestCapabilities: unknown;
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
createBindings(data: unknown): Readonly<Record<string, unknown>>;
|
||||
messages: CustomJsxRendererMessages;
|
||||
}
|
||||
|
||||
export const CUSTOM_JSX_METHOD_COLORS = methodColors;
|
||||
|
||||
export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.id !== "string" ||
|
||||
typeof record.kind !== "string" ||
|
||||
!kinds.has(record.kind) ||
|
||||
typeof record.method !== "string" ||
|
||||
!methods.has(record.method) ||
|
||||
typeof record.minimumBoardPermission !== "string" ||
|
||||
!permissions.has(record.minimumBoardPermission)
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
method: record.method,
|
||||
minimumBoardPermission: record.minimumBoardPermission,
|
||||
} as CustomJsxRequestCapability,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
class RendererErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError(error: Error): void },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
public state = { error: null } as { error: Error | null };
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
public componentDidCatch(error: Error, _info: ErrorInfo) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
public render() {
|
||||
return this.state.error ? <ErrorAlert error={this.state.error} /> : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorAlert({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{error.message}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomJsxRenderer({
|
||||
template,
|
||||
data,
|
||||
requestCapabilities: rawCapabilities,
|
||||
components,
|
||||
createBindings,
|
||||
messages,
|
||||
}: CustomJsxRendererProps) {
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const bindings = useMemo(() => createBindings(data), [createBindings, data]);
|
||||
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
return { ...renderSafeJsx({ template, components, bindings }), error: null };
|
||||
} catch (error) {
|
||||
return { node: null, warnings: [], error: error instanceof Error ? error : new Error(String(error)) };
|
||||
}
|
||||
}, [bindings, components, template]);
|
||||
useEffect(() => setParseErrors([]), [bindings, template]);
|
||||
const handleError = useCallback(
|
||||
(error: Error) =>
|
||||
setParseErrors((current) => {
|
||||
if (current.length >= 5 || current.includes(error.message)) return current;
|
||||
return [...current, error.message];
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
rendered.warnings.forEach((warning) => handleError(new Error(warning)));
|
||||
if (rendered.error) handleError(rendered.error);
|
||||
}, [handleError, rendered]);
|
||||
|
||||
if (!template.trim())
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.noTemplate}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
{capabilities.length > 0 && (
|
||||
<Group justify="flex-end" mb={4}>
|
||||
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
leftSection={<IconNetwork size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{messages.interactive}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.networkCapabilities}
|
||||
</Text>
|
||||
{capabilities.map((capability) => (
|
||||
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{capability.id}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColors[capability.method] ?? "gray"} variant="light">
|
||||
{capability.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color="gray" variant="outline">
|
||||
{capability.minimumBoardPermission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
)}
|
||||
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
|
||||
{rendered.error ? (
|
||||
<ErrorAlert error={rendered.error} />
|
||||
) : (
|
||||
<RendererErrorBoundary key={template} onError={handleError}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
)}
|
||||
</Box>
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.templateWarnings(parseErrors.length)}
|
||||
</Text>
|
||||
{parseErrors.map((message) => (
|
||||
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{message}
|
||||
</Text>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { Badge, Code, Image, Text, Title } from "@mantine/core";
|
||||
|
||||
export const SubFetchDataContext = createContext<unknown>(undefined);
|
||||
|
||||
const BLOCKED_KEYS = new Set(["constructor", "prototype", "__proto__"]);
|
||||
|
||||
export function normalizeParams(value: unknown) {
|
||||
if (value === undefined) return {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const result: Record<string, string | number | boolean> = {};
|
||||
for (const [key, param] of Object.entries(value)) {
|
||||
if (BLOCKED_KEYS.has(key)) return null;
|
||||
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
|
||||
result[key] = param;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getByPath(value: unknown, path?: string): unknown {
|
||||
if (!path?.trim()) return value;
|
||||
return path.split(".").reduce<unknown>((current, key) => {
|
||||
if (current == null || typeof current !== "object" || BLOCKED_KEYS.has(key)) return undefined;
|
||||
return Object.hasOwn(current, key) ? (current as Record<string, unknown>)[key] : undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
export function formatDisplayValue(value: unknown): string {
|
||||
if (value == null) return "—";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export interface SubDataProps {
|
||||
path?: string;
|
||||
as?: string;
|
||||
order?: number;
|
||||
size?: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
fw?: number;
|
||||
c?: string;
|
||||
alt?: string;
|
||||
fit?: string;
|
||||
w?: string | number;
|
||||
h?: string | number;
|
||||
radius?: string | number;
|
||||
}
|
||||
|
||||
export function SubData({ path, as = "Text", ...props }: SubDataProps) {
|
||||
const value = getByPath(useContext(SubFetchDataContext), path);
|
||||
if (value === undefined && path) return <Text c="dimmed">—</Text>;
|
||||
const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
|
||||
if (as === "Title") {
|
||||
return <Title order={(props.order as 1 | 2 | 3 | 4 | 5 | 6) ?? 3}>{displayValue}</Title>;
|
||||
}
|
||||
if (as === "Badge") {
|
||||
return (
|
||||
<Badge color={props.color} variant={props.variant as never} size={props.size as never}>
|
||||
{displayValue}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (as === "Code") return <Code block>{displayValue}</Code>;
|
||||
if (as === "Image") {
|
||||
const src = safeDisplayImageUrl(value);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<Image src={src} alt={props.alt ?? ""} fit={props.fit as never} w={props.w} h={props.h} radius={props.radius} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text size={props.size as never} fw={props.fw} c={props.c}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function safeDisplayImageUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return value;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CustomWidgetDisplayAdapters, CustomWidgetDisplayComponent } from "./display-types";
|
||||
import { createRawDisplay, KeyValueDisplay, StatusIndicatorDisplay, TableDisplay } from "./collection-displays";
|
||||
import { CountGridDisplay, ProgressBarsDisplay, SingleValueDisplay, StatGridDisplay } from "./metric-displays";
|
||||
|
||||
export function createCustomWidgetDisplayComponents(
|
||||
adapters: CustomWidgetDisplayAdapters,
|
||||
): Record<string, CustomWidgetDisplayComponent> {
|
||||
return {
|
||||
singleValue: SingleValueDisplay,
|
||||
keyValue: KeyValueDisplay,
|
||||
table: TableDisplay,
|
||||
statGrid: StatGridDisplay,
|
||||
progressBars: ProgressBarsDisplay,
|
||||
statusIndicator: StatusIndicatorDisplay,
|
||||
countGrid: CountGridDisplay,
|
||||
raw: createRawDisplay(adapters.openJsonLabel),
|
||||
actionButton: adapters.actionButton,
|
||||
customJsx: adapters.customJsx,
|
||||
} satisfies Record<string, CustomWidgetDisplayComponent>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export type CustomWidgetDisplayData = Readonly<Record<string, unknown>>;
|
||||
export type CustomWidgetDisplayComponent = ComponentType<{ data: CustomWidgetDisplayData }>;
|
||||
|
||||
export interface CustomWidgetDisplayAdapters {
|
||||
actionButton: CustomWidgetDisplayComponent;
|
||||
customJsx: CustomWidgetDisplayComponent;
|
||||
openJsonLabel: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { ActionButton, ToggleSwitch } from "./actions";
|
||||
export { CustomWidgetRuntimeProvider, useCustomWidgetRuntime, useWidgetDefinitionId } from "./context";
|
||||
export { SubData } from "./data";
|
||||
export { RefreshButton } from "./refresh-button";
|
||||
export { SubFetch } from "./sub-fetch";
|
||||
export { createCustomWidgetDisplayComponents } from "./display-registry";
|
||||
export { CustomJsxRenderer, CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./custom-jsx-renderer";
|
||||
export type { CustomJsxRendererMessages, CustomJsxRendererProps } from "./custom-jsx-renderer";
|
||||
export type { ActionButtonProps, ToggleSwitchProps } from "./actions";
|
||||
export type { SubDataProps } from "./data";
|
||||
export type { RefreshButtonProps } from "./refresh-button";
|
||||
export type { SubFetchMetadata, SubFetchProps } from "./sub-fetch";
|
||||
export type * from "./display-types";
|
||||
export type * from "./types";
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Card, Flex, Group, Progress, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import type { CustomWidgetDisplayData } from "./display-types";
|
||||
|
||||
export function SingleValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const size = String(data.valueSize ?? "lg");
|
||||
const labelAbove = data.labelPosition === "above";
|
||||
const label = data.label ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{String(data.label)}
|
||||
</Text>
|
||||
) : null;
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="xs">
|
||||
{labelAbove && label}
|
||||
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
|
||||
{String(data.value ?? "—")}
|
||||
{data.unit ? ` ${String(data.unit)}` : ""}
|
||||
</Title>
|
||||
{!labelAbove && label}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricItem {
|
||||
label: string;
|
||||
unit: string;
|
||||
color: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function StatGridCard({ item, cardStyle }: { item: MetricItem; cardStyle: string }) {
|
||||
const { ref, height, width } = useElementSize();
|
||||
const isWide = width > height + 20;
|
||||
const background = cardStyle === "outline" ? "transparent" : `var(--mantine-color-${item.color}-light)`;
|
||||
return (
|
||||
<Card ref={ref} p="sm" radius="md" bg={background} withBorder={cardStyle === "outline"} h="100%">
|
||||
<Flex h="100%" align="center" justify="center" direction={isWide ? "row" : "column"} gap={isWide ? 8 : 4}>
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
|
||||
<Text size="md" fw={700} ta="center" lh={1.1}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
{height > 38 && (
|
||||
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const items = (data.items as MetricItem[] | undefined) ?? [];
|
||||
const columns = Number(data.columns ?? 2);
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={columns}
|
||||
spacing="xs"
|
||||
p="xs"
|
||||
h="100%"
|
||||
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<StatGridCard key={index} item={item} cardStyle={String(data.cardStyle ?? "filled")} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBarsDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const bars = (data.bars as Array<MetricItem & { value: number; max?: number }> | undefined) ?? [];
|
||||
const sizes: Record<string, number> = { sm: 8, md: 14, lg: 22 };
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="sm" p="sm">
|
||||
{bars.map((bar, index) => {
|
||||
const max = bar.max ?? 100;
|
||||
const percentage = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
|
||||
const value =
|
||||
data.showPercentage !== false ? `${percentage.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`;
|
||||
return (
|
||||
<Stack key={index} gap={4}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" fw={500}>
|
||||
{bar.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size={sizes[String(data.barSize ?? "md")] ?? 14}
|
||||
color={bar.color}
|
||||
radius="sm"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const items = (data.items as MetricItem[] | undefined) ?? [];
|
||||
return (
|
||||
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
|
||||
{items.map((item, index) => (
|
||||
<Stack key={index} align="center" justify="center" gap={0}>
|
||||
<Text size={String(data.valueSize ?? "md")} fw={700}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" ta="center">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
|
||||
export interface RefreshButtonProps {
|
||||
label?: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
|
||||
const runtime = useCustomWidgetRuntime();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const refresh = async () => {
|
||||
if (!runtime.itemId && !runtime.previewSessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await runtime.port.invalidate({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
targets: ["parent", "*"],
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
variant={variant as never}
|
||||
size={size as never}
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void refresh()}
|
||||
loading={loading}
|
||||
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
|
||||
>
|
||||
{label ?? runtime.messages.refresh}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user