Primer
Yukta runs workflows (graphs of steps) on a server. People still need to see them: which sessions exist, what a workflow looks like, which run is in progress, which step failed, what the logs say, and they need buttons to start, pause or stop a run. Without a UI they would do this with curl and by reading JSON. The ui module is that screen. It is a React app built once into static files, served by the Yukta server, and it asks the server for everything it shows.Where the ui sits
The vocabulary in eight cards
One example, end to end
You open a workflow’s run page and watch a run live. Click a step to see what happens, why, and where it can go wrong.Open run page
Load page code
Fetch graph+runs
Pick a run
Progress stream
Log stream
Paint the DAG
Pause a node
A visit in plain words
Arrive
Browse
Build
Run
Watch
Steer
Look around
Components
Shell & Routing
layout, lazy routes Draws the header, breadcrumb and footer and maps each address to a screen. All routes except the home page are loaded on demand.- The routes are: / (session list; /sessions redirects there), /sessions/new, /sessions/:sessionId, /sessions/:sessionId/workflow/:workflowId (the run view), the same path plus /edit (the builder), /plugins, /history and /control. The header links Sessions, Plugins, History and Control.
- Every route except the session list is declared lazy: its code is downloaded the first time you visit. Example: the run view needs React Flow and dagre, about 257 KB; users who only browse sessions never download it.
- boot serves index.html for any GET that is not /api, /actuator, /mcp, /v3, /swagger-ui, /webjars, /sse or a static file pattern (js, css, fonts, images, json, webmanifest). Without that fallback, reloading /sessions/demo would give a 404, because that path exists only in the browser.
- index.html is sent with no-cache and a Content-Security-Policy (default-src ‘self’; inline styles allowed; no objects, no framing). Files under /assets are hashed and cached for a year. The theme (light, dark or system) is read from localStorage by a tiny script before the app loads, to avoid a flash.
- Breadcrumbs link the ‘Sessions’ crumb to /sessions, which redirects to /. The run page can replace its crumb text with the workflow description.
- Guarantees. An unknown address inside the app shows the shell, never a server 404; a file path that does not exist is a real 404, not the app.
- State. Only the URL and the theme choice (localStorage key ‘theme’). Everything else is rebuilt from the server on reload.
- Ceiling. Each route is a separate download, so the first visit to a heavy page pays a round trip. The build budget keeps any one file under 300 KB.
- On failure. If a route file fails to download, that screen does not render. There is no custom error boundary that I found in the router setup; the router’s default error screen applies.
boot: SpaWebFluxConfig (fallback and CSP), Vite dev server proxies /api, /sse and /actuator to :8080
API Client & Cache
fetchApi, errors, queries The single path for REST calls: builds safe URLs, unwraps the response envelope, turns failures into typed errors and caches answers.- fetchApi sends the request with a JSON content type. Headers passed by the caller are merged on top, so an Idempotency-Key does not drop the content type. It reads the JSON body even on errors; if the body is not JSON (a 502 page from a proxy) it falls back to the status text.
- A non-2xx answer throws ApiError carrying the server’s message and a list of field errors (field and message). Example: saving with a bad session id shows ‘sessionId: must not be blank’. A 204 returns nothing; a successful non-JSON body is an error.
-
The api tagged template builds paths such as api
/api/sessions/$\{id\}and URL-encodes every value. An id containing ’/’ or ’?’ therefore cannot reach a different endpoint. The action suffix of node commands (for example step/enable) comes from a closed list and is appended unencoded on purpose. - TanStack Query is configured with: fresh for 5 minutes, kept 10 minutes after last use, no refetch on window focus, and one retry for queries and for mutations. Mutations such as start and stop also invalidate the status and workflow-list queries on success.
- fetchApi adds no Authorization or X-API-Key header and no credentials option. The server’s ApiKeyFilter requires one on every /api/ request when yukta.api-key is set.
- Guarantees. Every REST failure reaches the screen as an Error with a readable message; ids are always encoded.
- State. The query cache is in memory only: gone on reload.
- Ceiling. The 5 minute freshness means other people’s changes can be invisible for that long. One retry for mutations can repeat a non-idempotent call (risk startRetryKey).
- On failure. A network failure rejects the fetch and is shown by the screen’s own error state (for example ‘Failed to load sessions’ with a retry button).
queryClient: staleTime 5 min, gcTime 10 min, retry 1, pnpm run api:types (regenerate OpenAPI types)
Live Stream Hook
EventSource + backoff Subscribes the page to a server-sent-event stream, parses each frame as JSON and reconnects after errors.- The hook takes a URL (or null to stay idle) and a callback. Changing the URL closes the old stream and opens a new one; leaving the page closes it.
- A frame that is not valid JSON is dropped and the stream carries on. Example: a keep-alive comment or a half-written frame cannot crash the page.
- On an error the hook closes the stream and, if reconnecting is allowed, opens a new one after 1 s, doubling each time up to 30 s. The counter resets once a connection opens, so a long outage does not leave the next blip with a 30 s delay.
- The server numbers events and honours a Last-Event-ID header and sends a keep-alive every 15 s. The hook closes the browser’s built-in EventSource and creates a new one on each retry, so the browser never sends Last-Event-ID. Every reconnect therefore replays from the start and the consumers remove duplicates.
- An EventSource cannot set request headers. Any scheme that needs an Authorization header on the stream would need a different transport.
- Guarantees. At most one open stream per URL; a closed page leaves no stream or timer behind.
- State. Connection status (connecting, open, error) in memory. Nothing survives a reload.
- Ceiling. Each open run page holds two long-lived HTTP connections (progress and logs). Browsers limit connections per host over HTTP/1.1 (six is typical), so several run pages open in tabs can starve each other.
- On failure. When the server ends a log stream at run end, the browser sees an error and the hook reconnects (while the run is not yet seen as finished) and replays everything, which is then de-duplicated.
RECONNECT_BASE_MS 1000, RECONNECT_MAX_MS 30000
Run View
canvas, progress, controls The workflow page: a React Flow graph, a progress side panel, run controls, a run picker and the log terminal, all for one selected run.- On load it fetches the workflow graph and the run list, selects the run from ?exec= or the newest, then combines a status snapshot with the live progress stream. The stream is opened only while the run is not finished.
- The graph is laid out left to right by dagre with nodes 240 by 96 px and fixed spacing. Nodes cannot be dragged or connected here. A node shows its status icon, a progress percentage when the plugin reports one in its metadata, and output port handles; an edge animates when its source is completed and its target is running.
- RunControls: Run opens a dialog for an optional JSON payload (it must be a JSON object) and sends POST /api/workflow/start with a fresh Idempotency-Key. Pause and Resume are enabled by the run’s status; Restart is enabled for a finished run.
- Stop has four choices. ‘Stop this run’ stops the selected run. ‘Drain’, ‘Cancel’ and ‘Kill all runs’ call the workflow-wide stop with the matching mode, so they also stop runs started by other people. Each asks for confirmation first.
- NodeControlsMenu lists only the actions that fit the node’s current state, for example Retry only for a failed node and ‘Restart from here’ for any node that has started. The page shows the new state only when the server’s next progress frame reports it.
- A 429 from start shows ‘The scheduler is at its limit. Try again shortly.’ Other errors show the server’s message.
- Guarantees. Buttons are enabled only for states where they make sense, and destructive stops ask for confirmation.
- State. Selected run in the URL; panel open/closed and log filters in memory. Run data is never stored in the browser.
- Ceiling. The graph is drawn in the browser; very large workflows (the server allows up to 500 nodes) mean hundreds of React Flow nodes and animated edges. The run list is whatever the server returns (default up to 500) with no paging.
- On failure. If the graph or run list fails to load, the page shows ‘Failed to load workflow’ with no retry button.
?exec=<executionId>, POST /api/workflow/start, /api/executions/{id}/{action}
Workflow Builder
forms from config schema Creates sessions and edits one workflow at a time through forms: nodes, edges, per-node settings and the engine failure policy.- ‘New session’ asks for id, name, description, initiator and project path, saves a starter workflow (an API_TRIGGER wired to a CONSOLE_TERMINAL, because a session needs at least one workflow) and opens the editor on it.
- The editor loads the complete session from GET /api/sessions/{id}/config (always fresh, staleness 0) so that saving does not lose tags or other workflows. You pick a plugin type, press Add node (ids are generated like http-request-1), and add edges with three dropdowns: source, port and target.
- Selecting a node shows the plugin’s config schema as a form: text, number, checkbox, choice list and expression box. LIST and MAP settings are edited as JSON that is applied only when it parses. SECRET settings take a placeholder such as decrypted:<key> instead of the value. A plugin with no schema gets a raw JSON editor and its usage text. An empty field is removed so the plugin default applies.
- Every node also has an ‘Error handling and retries’ panel for the engine policy: onError (FAIL, CONTINUE, ROUTE), trigger rule (ALL_SUCCESS, ALL_DONE, ONE_SUCCESS), retry, element timeout, deadline and failure budget.
- Validate sends only the current workflow to POST /api/sessions/validate. The server uses the same rules as execution and answers valid or not with the first problem and each node’s output ports, which fill the edge port dropdown. Save validates first and, if valid, sends the whole session to POST /api/sessions, then opens the run view.
- The canvas preview is the same React Flow canvas as the run view, fed from the draft.
- Guarantees. A workflow is saved only after the server validated it moments before; the UI never decides validity itself.
- State. The draft lives only in memory: leaving the page or reloading discards it. Only Save writes to the server.
- Ceiling. The edge editor is dropdown based, so graphs with many nodes are tedious. Saving replaces the whole session, so two editors of the same session overwrite each other.
- On failure. Validation or save errors are shown inline (field errors joined as ‘field: message’). A failed save leaves the draft intact.
POST /api/sessions/validate, POST /api/sessions, GET /api/sessions/{id}/config, GET /api/plugins and /api/plugins/{type}
Oversight Pages
plugins, history, control Three pages for discovery and oversight: the plugin catalogue, recent runs of a session, and supervisors and control-bus nodes.- Plugins: a filterable list grouped as Triggers, Processors, Terminals. The detail shows description, output ports, a table of config fields (name, type, description, required star), the usage pattern and a preview of how the node looks on the canvas. The selected type is kept in ?type=.
- History: pick a session (default the first) and see its newest 50 runs from GET /api/workflow/{session}/history?limit=50. Each id links to the run page with ?exec=. The server pages with a cursor; the UI asks for one page of 50 and does not follow the cursor.
- Control: lists node names registered on the control bus (GET /api/control/nodes, or per workflow). Typing a workflow id enables a Heartbeat button per node. A Supervisors table (GET /api/supervisors) shows each supervisor or control plane with kind, type, order, live or dry-run mode, rate cap and counts of applied, refused, recorded and dropped commands since startup.
- Both the node list and the supervisor table refetch every 5 seconds while the page is open.
- Guarantees. Read-only: these pages change nothing on the server.
- State. No local state beyond filters and the selected item.
- Ceiling. Each open Control page makes two requests every 5 seconds per browser. History shows at most 50 runs per session.
- On failure. Each list has its own error state with a retry button; a failed heartbeat shows ‘No heartbeat’.
GET /api/plugins, /api/workflow/{session}/history, /api/control/nodes, /api/supervisors
Status Mapping
one translation point Turns the server’s free-text statuses into the UI’s states, in one file.- Node statuses map to six UI states: completed, running, pending, failed, paused, skipped. Example: SUCCESS is completed; FAILURE, TIMED_OUT and ABORTED are failed; STOPPED, CANCELLED and UPSTREAM_FAILED are skipped (the node did not run because of something else); an unknown word shows as pending.
- Run statuses map to coarse buckets (running, success, failure, stopped, paused, pending, unknown). The buttons use them: Pause needs ‘running’, Resume needs ‘paused’.
- NODE_STOPPED is special. When one node is stopped the server writes NODE_STOPPED as the whole run’s status while the other nodes keep going. The mapper therefore treats it as running, not as finished. Before this was fixed, stopping one node made the page stop following a live run.
- A separate list decides which statuses are terminal, meaning the run is over. The progress stream is closed and the Restart button is enabled only for these. A live control state (PAUSED, STOPPING) overrides the shown status while the run is not terminal.
- The statuses are plain strings in the server’s OpenAPI document, so a typo or a new status is not caught by the type checker.
- Guarantees. Every screen gets the same meaning for a status, because nothing else interprets raw words.
- State. None. Pure functions.
- Ceiling. A new server status needs a code change here, and until then it renders as pending or unknown.
- On failure. Unknown input degrades quietly instead of failing. That avoids crashes but can hide a new state.
Log Terminal
batch, cap, virtualise Collects the log stream into a bounded buffer and draws only the visible rows.- Incoming entries are held in a small queue and added to the screen once every 100 ms. A burst of 500 lines costs one redraw.
- Each entry gets a key made of timestamp, plugin id, stream and message. A key already seen is dropped, which removes the history the server replays after each reconnect. The set of remembered keys is itself capped at 2,000.
- The list keeps the newest 2,000 entries. Rows are 20 px high and drawn through a virtual list: only rows in view plus a margin of 20 exist in the page.
- The panel offers a text filter, auto-scroll, line wrapping (rows are then measured instead of fixed), copy and clear. Levels get colours: WARN, ERROR and DEBUG each have a colour token.
- Guarantees. Memory and page size stay bounded however long the run is.
- State. The 2,000-line buffer is in memory and resets when you switch runs.
- Ceiling. Because of the 2,000 cap, long runs show only the tail; the rest is on the server.
- On failure. Dropped duplicates look like missing lines when two real lines are identical and share a timestamp.
MAX_LOG_ENTRIES 20000, LOG_FLUSH_MS 100, row height 20 px
Design System
tokens, shadcn, icons Defines colours, type, spacing, dark mode, shared page pieces and the icon font once, for every screen.- Tailwind CSS 4 is configured in CSS, not in a JavaScript file. Colours are variables (oklch values) with light and dark sets, plus Material-style alias names such as on-surface. The brand colours come from the logo: blue (primary), slate (neutrals) and orange (the ‘attention’ pulse of a running node).
- Plugin categories have their own colours (trigger orange, processor blue, terminal slate), used only on the canvas, the plugin catalogue and edges. Node effects (glow, flowing edges) are switched off when the user prefers reduced motion.
- Components such as button, input, table and dropdown are copied-in shadcn primitives built on Base UI. Re-adding one needs three fixes by hand: the cn import path, Material Symbols instead of lucide-react, and the license header.
- Icons are Material Symbols Outlined from a self-hosted subset. A script scans src for icon names, asks Google Fonts for a font with just those glyphs and writes src/assets/material-symbols-subset.woff2 (about 13.7 KB now, versus 3.96 MB for the full font).
- Fonts are Inter Variable and JetBrains Mono, packaged from npm. Minimum text size is 12 px. Shared pieces: PageHeader, StateMessage, CodeBlock and StatusChip.
- Guarantees. No screen hardcodes a colour for status or logs; dark mode and reduced motion are honoured everywhere the tokens are used.
- State. The theme choice is stored in localStorage; the rest is static.
- Ceiling. pnpm run icons needs network access, and an icon added without regenerating the font shows as blank text.
- On failure. A missing glyph renders as its raw name. The build only checks the font’s size, not its contents.
pnpm run icons, pnpm run favicons, src/index.css (@theme tokens)
Build & Serving
Vite, Gradle, guards Compiles and checks the app, packs it into the ui jar and defines the rules that keep it small and licensed.- pnpmBuild runs: type check of source, type check of tests, vite build, then a size check. The output dist/ is copied into the jar under static/, and boot serves it. The Gradle task depends on pnpmInstall, so Node and pnpm come from the build, not from the machine.
- The size check fails the build if any JavaScript file is over 300,000 bytes or the icon font is over 100,000 bytes. Vite splits vendor code into stable chunks (react, tanstack, router and ui-kit) so they stay cached across releases.
- A compile-time test fails the type check when a hand-written UI type and the matching server schema stop having the same fields. ‘pnpm run api:types — —check’ fails when the committed generated types are stale, but it needs a running server, so it is not part of the normal build.
- ‘:ui:check’ runs two guards: validateHeaders (every source file under src, the config files and index.html must carry the SPDX license header) and verifyPackageVersion (package.json’s version must equal the Gradle project version).
- Unit tests (Vitest with Testing Library, a 100% threshold configured for coverage) run with ‘:ui:pnpmTest’ or ‘pnpm test’. E2E tests (Playwright against mocked APIs) run with ‘:ui:pnpmTestE2e -PrunE2e=true’ and in a CI job. In build.gradle.kts the unit tests are commented out of ‘check’.
- Guarantees. A build that produces an oversized chunk, an oversized icon font, a file without a license header or a mismatched version number fails.
- State. dist/ and build/ are generated output.
- Ceiling. The build needs Node tooling and, for icons and API types, network or a running server.
- On failure. A failure stops the Gradle build, so the jar is not produced.
./gradlew :ui:build, ./gradlew :ui:pnpmTest, ./gradlew :ui:pnpmTestE2e -PrunE2e=true, pnpm run lint:headers
Scenarios
Watch a run
One visit to a run page, from the first request to the run finishing. Use it first to learn the pieces.Open the run page
Fetch the graph and the runs
Choose a run, take a snapshot
Frames arrive, the graph repaints
Logs batch in
The run ends
A dropped stream
What happens when a live stream drops, and why duplicates are expected.The connection breaks
Close and wait
Open a new stream
Replay is absorbed
Back to normal
Build and save a workflow
How a workflow is created, edited, checked by the server and saved, and where drafts can be lost.Create a session
Load the draft
Add nodes and edges
Validate
Save
Navigate away by accident
Start, steer and stop
Starting, pausing, stopping and restarting a run, and why the screen only changes when the server says so.Run with a payload
Follow the new run
Pause or act on a node
Stop
Restart
A protected server
Why the bundled UI cannot work against a server that requires an API key (a failure scenario).The operator exposes the server
The page still loads
The first call is refused
Streams cannot be fixed with a header
Workarounds today
Capabilities
What this module does, how, what it guarantees and where the guarantee stops.Sessions and builder
Run view
Live data
Oversight
Platform
Run page
Control
What the UI keeps, and what it enforces
The UI owns no durable data. This section lists its timings and limits, what vanishes on a reload, and which rules are enforced by the code or the build and which are not.What survives a reload
Everything about runs and workflows lives on the server and is fetched again.Timings and limits
Values taken from the code. Changing one means a code change, there are no runtime settings.Enforced or not
Reference
Routes
All routes are defined in one router file. Only the session list is in the main bundle.Endpoints the UI calls
Everything goes to the same origin under /api. Paths marked legacy are deprecated aliases on the server; the replacement exists.Status mapping
Raw server status to what the screen shows. ‘Terminal’ means the run is over.Bundle budget
Measured from the ui/dist/assets folder present in the working tree when this page was written (it may be older than the sources). The build fails if a script exceeds 300,000 bytes or the icon font exceeds 100,000 bytes. The previous guide recorded: icon subset 11,188 B (full font 3,960,036 B), WorkflowDetailsPage chunk 272,541 B, and a single 887,435 B script before splitting.Commands and rules
Architecture decisions
A single-page app served from the same jar and origin
A single-page app served from the same jar and origin
Live data over server-sent events with replay and client de-duplication
Live data over server-sent events with replay and client de-duplication
One status chokepoint over free-text server statuses
One status chokepoint over free-text server statuses
Form-based builder driven by plugin schemas, not drag-and-drop
Form-based builder driven by plugin schemas, not drag-and-drop
Hand-written types as working types, generated types as a drift check
Hand-written types as working types, generated types as a drift check
Hard size budgets with lazy routes and vendor chunks
Hard size budgets with lazy routes and vendor chunks
Five minute query cache plus invalidation on change, no refocus refetch
Five minute query cache plus invalidation on change, no refocus refetch
Design tokens in Tailwind 4 with copied-in shadcn components on Base UI
Design tokens in Tailwind 4 with copied-in shadcn components on Base UI