DEV Community

Cover image for Building a Live UI Schema Playground for OpenEverest (LFX Mentorship 2026)
Naman Raj
Naman Raj

Posted on

Building a Live UI Schema Playground for OpenEverest (LFX Mentorship 2026)

Hey everyone šŸ‘‹ I'm Naman. For my LFX Mentorship (Term 2, 2026) I worked on OpenEverest, and the thing I built is the UI Schema Playground: a page inside OpenEverest where plugin developers write a UI schema in YAML and watch the generated form update live, with validation errors shown right in the editor.

This post covers what I built and how it got there, step by step, including the parts I ended up throwing away. I'll explain the less common terms as they come up, so you don't need to know OpenEverest beforehand.

Table of Contents


The problem

OpenEverest is an open source platform for running databases on Kubernetes. Support for each database engine comes from a provider, which works like a plugin.

Each provider is registered in the cluster as a Provider object. That's a custom Kubernetes object, defined by a CRD (Custom Resource Definition, which is how you teach Kubernetes about a new kind of object). Along with everything else, the Provider object carries a UI schema: a YAML description of the "create database" form for that engine. The frontend reads the schema and renders the form with a component called UIGenerator.

A small schema looks like this:

replica:                 # topology: one way to deploy the database
  sections:
    basicInfo:           # section: one step of the form
      label: Basic Information
      components:
        numberOfNodes:   # component: one field in that step
          uiType: number           # which input to render
          path: spec.replica.nodes # where the value goes in the API request
          fieldParams:
            label: Number of nodes
            defaultValue: 3
          validation:
            min: 1
            max: 7
Enter fullscreen mode Exit fullscreen mode

A topology is a deployment layout, like a single replica set or a sharded cluster. Each topology gets its own form, split into sections (the steps of a multi-step form). Each section holds components, which are fields or groups of fields.

The problem was the feedback loop. The only way to see what a schema rendered was to put it in a Provider object and deploy it to a running cluster, and every typo meant another deploy. The goal was to cut that down to typing and looking.


The constraint: a strict CSP

There was already a prototype page for this, built on Monaco (the editor inside VS Code). It didn't work in production because of OpenEverest's Content Security Policy.

A CSP is a header the server sends with the page. It tells the browser what that page is allowed to load and run. If an attacker manages to inject a script (an XSS bug), a strict CSP stops the browser from running it. OpenEverest's policy is strict:

  • Scripts only from its own origin (script-src 'self'). Nothing loads from a CDN.
  • No unsafe-eval, so code can't build and run JavaScript from strings with eval() or new Function().
  • No unsafe-inline, so <script> and <style> blocks written directly into the HTML are blocked.
  • Styles need a nonce. A nonce is a random value the server generates for each response. Only <style> tags carrying that exact value are applied, which an attacker can't guess.

Monaco loads its scripts from a CDN and needs unsafe-eval, so the browser blocks it. OpenEverest manages production databases and their credentials, so loosening the policy wasn't an option. Whatever editor I used had to work inside the CSP as it is.


What the playground does

The playground lives at /plugin-developer. Here's what it does:

  • YAML editor (CodeMirror 6) with syntax highlighting, line numbers, undo/redo, and a Format YAML button. It has a lint gutter, the narrow column next to the line numbers where error markers appear, like in VS Code. It follows the app's light/dark theme.
  • Real-time validation: YAML syntax errors first, then a check that the schema has the right structure, both underlined on the line that's wrong.
  • Live preview rendered by the real UIGenerator, with a stepper to move through sections and a picker to switch topologies. It only renders when the schema is valid. Otherwise the editor shows how many errors are left.
  • Live API data. Some fields, like storage classes or monitoring configs, load their options from OpenEverest's API. In Kubernetes those resources live in a namespace (a named group of resources in the cluster), so they only load once one is set. The preview has a namespace picker that defaults to the first available one.
  • Resizable split pane between editor and preview.

Getting to this version took a few rounds, and the first one looked quite different.


First version: CodeMirror 6 in a sandboxed iframe

The issue allowed two ways out: use an editor that works under the CSP, or isolate the editor somewhere with its own, looser policy. My first version did the second, with CodeMirror 6 running inside a sandboxed iframe.

An iframe is a page embedded inside another page. Adding the sandbox attribute locks it down. With sandbox="allow-scripts" and without allow-same-origin, the iframe can run JavaScript, but the browser treats it as an opaque origin: a stranger with no access to the parent page's cookies, localStorage, or login token. So even if something went wrong inside the editor, it couldn't reach anything sensitive.

OpenEverest page (strict CSP)
│
ā”œā”€ā”€ <iframe sandbox="allow-scripts" srcdoc="...">   own CSP, opaque origin
│      └── CodeMirror 6 bundle
│             │  ^
│     change  │  │  setDiagnostics / setTheme      (postMessage)
│             v  │
ā”œā”€ā”€ React host (CodeMirrorEditor)
│      └── validate YAML --> parsed schema --> UIGenerator preview
Enter fullscreen mode Exit fullscreen mode

Here's how it was put together:

  • A build script used Vite to bundle all of CodeMirror into a single IIFE file (an "immediately invoked function expression", a script that runs on its own as soon as it loads, with no imports). It also escaped any </script in the code, so the bundle couldn't accidentally close the tag it sat in.
  • That bundle was placed into the iframe's srcdoc, an attribute that holds the iframe's whole HTML as a string, so there's no separate file to serve. That HTML carried its own CSP.
  • The page and the editor talked through postMessage, the browser's way of sending messages between windows that can't touch each other directly. I gave it a small typed protocol:
// Parent (React host) → iframe (CM6 bundle).
export type HostToEditorMessage =
  | { type: 'init'; value: string; theme: EditorTheme }
  | { type: 'setTheme'; theme: EditorTheme }
  | { type: 'setDiagnostics'; diagnostics: Diagnostic[] };

// iframe (CM6 bundle) → parent (React host).
export type EditorToHostMessage =
  | { type: 'ready' }
  | { type: 'change'; value: string }
  | { type: 'error'; message: string };
Enter fullscreen mode Exit fullscreen mode

Any window can send a message, so every incoming one was checked with a type guard (a function that verifies at runtime that the data really has the expected shape), and the page ignored messages that didn't come from its own iframe. If the editor didn't report ready within 4 seconds, the page fell back to a plain <textarea>.

The validator came with this first version, and it survived every later change.

The validator

TopologyUISchemas describes the schema shape, but it's only a TypeScript type, and TypeScript types disappear once the code runs in the browser. To check what a user actually typed, I needed a runtime validator, so I wrote one with Zod, a library for describing a data shape in code and checking real values against it. Validation runs in two layers:

  1. YAML syntax. The yaml package parses the text. Its LineCounter helper turns error positions into line and column numbers for the editor.
  2. Structure. A Zod schema checks the shape UIGenerator depends on: topology → sections → components or groups, with a valid uiType on every field. Groups can contain other groups, so the schema refers to itself through z.lazy. .passthrough() keeps the check to that skeleton and lets any other keys through, so authors can use any field options.

The tricky part was putting the red underline in the right place. When Zod finds a problem, it reports a path into the data, like replica.sections.basicInfo.components.nodes. The YAML parser also produces a syntax tree: a tree of nodes that mirrors the document, where each node remembers where it starts in the text. I walk that tree along Zod's path and stop at the deepest node that exists:

  • If the error is about a plain value (say, a label that isn't a string), the underline lands on that value.
  • If the error is about a whole component or group (a missing or invalid uiType, or a group without components), the underline lands on its key, like nodes:.

A missing uiType has no line of its own, so the component key is the closest real place to point at. Rather than digging into Zod's internals for something more precise, I kept it simple and put the detail in the message: an invalid component says it needs a valid uiType and lists the allowed ones, and a missing key reads like Missing required key "sections".

I also fixed a crash in UIGenerator while I was at it. A field with no fieldParams (like a bare uiType: hidden) used to crash on destructuring.


Using the renderer's own field types

My first validator had the list of valid uiTypes typed out by hand. That list would go out of date the moment someone added a field type to the renderer. So I switched it to read from UIGenerator's own FieldType enum:

// Leaf uiTypes, from the renderer's own enum so the validator can't drift.
export const FIELD_UI_TYPES = Object.values(FieldType);

const fieldUiType = z.nativeEnum(FieldType);
Enter fullscreen mode Exit fullscreen mode

Now a new field type in the renderer is automatically valid in the playground, and the error message that lists the allowed types stays correct too.


Caching the editor bundle

The iframe needed that generated bundle file, so I'd hooked the bundle build into pnpm dev, pnpm test:unit, and pnpm build. That meant rebuilding CodeMirror on every run, which costs a few seconds each time even though the bundle almost never changes.

A simple "skip if the file already exists" check would have been fast, but risky. The bundle wasn't committed to git, so a fresh clone has to build it. And if someone changed the editor code, the old bundle would quietly stay in place.

So the build script takes a hash of everything the bundle depends on (a short fingerprint that changes whenever any input changes) and skips the build only if the fingerprint matches the last one:

  • the editor's entry file
  • the message protocol file
  • the build script itself
  • the exact installed @codemirror/* versions from pnpm-lock.yaml

That last one matters because the lockfile records the exact version of every installed package. Reading versions from there, not from package.json, catches updates that only change the lockfile. A --force flag skips the check entirely. (All of this got deleted later. More on that below.)

At the same point I moved the page from the old prototype route to /plugin-developer.


Deriving validation instead of storing it

In the first version, validation results were stored in React state. An effect re-ran validation whenever the text changed, and the Format YAML button ran it again separately. That's two code paths that have to stay in sync, and it's easy to add a third way of changing the text and forget to re-validate.

The fix was to stop storing it. The results are now derived from the text: computed from it on every render, with useMemo caching the result until the text changes.

// Derived, not state, so every path that changes the text stays in sync.
export const useSchemaValidation = (yamlText: string): SchemaValidation =>
  useMemo(() => validateSchema(yamlText), [yamlText]);
Enter fullscreen mode Exit fullscreen mode

Whatever changes the text (typing, formatting, anything added later), the errors and the parsed schema follow automatically.


Splitting the page into hooks

The page component held all of its logic inline, so I pulled it into three custom hooks. A custom hook is a function that bundles React state and effects so a component can reuse them and you can test them on their own:

  • use-schema-validation: the derived validation above.
  • use-preview-namespace: loads the namespaces and picks the first one by default, since the API-backed fields don't load without one.
  • use-split-pane: drag-to-resize, with widths as percentages of the container, kept between 20% and 80% so neither pane can be dragged away completely.

The page itself went from managing all of this to just wiring the hooks together.


Tests, and a bug in shared code

Next came tests for the page and all three hooks, and a folder rename from ui-generator-builder to plugin-developer to match the route.

While testing, I hit a bug in useTopology, the hook that tracks which topology is selected. If you live-edited the schema and renamed or removed the selected topology, the form kept pointing at one that no longer existed. The real database creation form uses the same hook, so the fix had to change nothing when the schema doesn't change:

// Fall back to the first topology when the selection goes stale after a
// schema change (e.g. live-edited in the playground). No-op for a fixed schema.
const effectiveTopology = topologies.includes(selectedTopology)
  ? selectedTopology
  : defaultTopology;
Enter fullscreen mode Exit fullscreen mode

The database creation form passes its topology in through use-form-engine, so it never hits this path, and its existing tests still pass.


Dropping the iframe

The iframe version didn't work under OpenEverest's production CSP. So I went with the other option from the start: take CodeMirror out of the iframe and put it directly into the React tree.

That meant deleting a lot of what I'd built: the srcdoc builder, the postMessage protocol, the bundling script, the generated bundle, the hash cache, and the extra build steps in package.json. CodeMirror is now a normal dependency that Vite bundles like everything else.

Running directly on the page works because CodeMirror 6 never uses eval, so script-src 'self' is fine. The one thing it does at runtime is inject <style> tags, which the nonce rule would block. OpenEverest already puts the nonce for each response in a <meta name="csp-nonce"> tag, and CodeMirror has a setting for exactly this, EditorView.cspNonce. The editor reads the nonce from the page and stamps it on every style tag it creates:

const readCspNonce = () =>
  document.querySelector("meta[name='csp-nonce']")?.getAttribute('content') ||
  '';

const state = EditorState.create({
  doc: value,
  extensions: [
    EditorView.cspNonce.of(readCspNonce()),
    lineNumbers(),
    highlightActiveLine(),
    history(),
    indentOnInput(),
    lintGutter(),
    yaml(),
    keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
    themeCompartment.current.of(themeExtension(theme)),
    // ...change listener
  ],
});
Enter fullscreen mode Exit fullscreen mode

The server needed one line. CodeMirror's lint package draws its squiggly underlines as small SVG images embedded straight into the CSS as data: URIs (the image data written inline instead of loaded from a file). The policy's img-src didn't allow those, so I added data::

// The YAML editor draws its inline lint underlines as data: SVG
// background-images (@codemirror/lint), so allow data: images.
cspbuilder.ImgSrc: {CSPSelf, "data:"},
Enter fullscreen mode Exit fullscreen mode

That's the only change to the policy. Still no unsafe-eval, no unsafe-inline, no CDN.

With no postMessage in between, React and CodeMirror now work on the same document directly, so I had to stop them from fighting:

  • Debouncing. User edits reach React only after 150 ms with no typing, instead of on every keystroke, so validation doesn't run constantly while you type. That's the same delay the iframe version used.
  • Tagging programmatic changes. When React pushes a new value into the editor (after Format YAML, for example), CodeMirror would normally report it as a change, and React would get its own update back as if the user had typed it. So I tag those updates with a CodeMirror annotation (a label attached to a change) and have the change listener ignore anything tagged.
  • Swapping the theme. The theme sits in a CodeMirror compartment, a slot in the editor's config that can be replaced later, so switching light/dark reconfigures the editor without recreating it.

The old protocol tests were replaced by a browser test for CodeMirrorEditor. The helper that converts a line and column into a character position, which the iframe used for lint ranges, stayed and got its own tests.


The code is under ui/apps/everest/src/pages/plugin-developer/ in the repo. Thanks for reading!

Top comments (2)

Collapse
 
atishyy27 profile image
Atishay Jain •

Interesting to read. Wonderful Naman.

Collapse
 
denyme24 profile image
Naman Raj •

thanks for the read.