Add attractor-web React frontend for pipeline dashboard

Bun-based React app with pipeline dashboard UI including event log,
graph view, context/checkpoint panels, question panel, and status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 13:47:03 -05:00
parent b4f65e2555
commit 366643d6ef
20 changed files with 845 additions and 0 deletions

34
attractor-web/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

111
attractor-web/CLAUDE.md Normal file
View file

@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

21
attractor-web/README.md Normal file
View file

@ -0,0 +1,21 @@
# bun-react-template
To install dependencies:
```bash
bun install
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

17
attractor-web/bun-env.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

39
attractor-web/bun.lock Normal file
View file

@ -0,0 +1,39 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"react": "^19",
"react-dom": "^19",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View file

@ -0,0 +1,2 @@
[serve.static]
env = "BUN_PUBLIC_*"

View file

@ -0,0 +1,20 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.ts"
},
"dependencies": {
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest"
}
}

27
attractor-web/src/App.tsx Normal file
View file

@ -0,0 +1,27 @@
import { useState } from "react";
import "./index.css";
import { StartForm } from "./StartForm";
import { PipelineDashboard } from "./PipelineDashboard";
export function App() {
const [pipelineId, setPipelineId] = useState<string | null>(null);
return (
<div className="app">
<div className="app-header">
<h1>Attractor</h1>
<span>Pipeline Dashboard</span>
</div>
{pipelineId ? (
<PipelineDashboard
pipelineId={pipelineId}
onBack={() => setPipelineId(null)}
/>
) : (
<StartForm onStart={setPipelineId} />
)}
</div>
);
}
export default App;

View file

@ -0,0 +1,39 @@
import { useCallback } from "react";
import { getCheckpoint, type Checkpoint } from "./api";
import { usePolling } from "./hooks";
interface CheckpointViewProps {
pipelineId: string;
active: boolean;
}
export function CheckpointView({ pipelineId, active }: CheckpointViewProps) {
const fetcher = useCallback(() => getCheckpoint(pipelineId), [pipelineId]);
const { data } = usePolling<Checkpoint | null>(fetcher, 2000, active);
if (!data) {
return (
<div className="panel">
<h3 className="panel-title">Checkpoint</h3>
<p className="context-empty">No checkpoint yet</p>
</div>
);
}
return (
<div className="panel checkpoint-view">
<h3 className="panel-title">Checkpoint</h3>
<div className="node-list">
<span className="node-tag current">{data.current_node}</span>
{data.completed_nodes.map((node) => (
<span key={node} className="node-tag completed">
{node}
</span>
))}
</div>
{data.logs.length > 0 && (
<pre>{data.logs.join("\n")}</pre>
)}
</div>
);
}

View file

@ -0,0 +1,46 @@
import { useCallback } from "react";
import { getContext, type ContextSnapshot } from "./api";
import { usePolling } from "./hooks";
interface ContextViewProps {
pipelineId: string;
active: boolean;
}
function formatValue(value: unknown): string {
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
}
export function ContextView({ pipelineId, active }: ContextViewProps) {
const fetcher = useCallback(() => getContext(pipelineId), [pipelineId]);
const { data } = usePolling<ContextSnapshot>(fetcher, 2000, active);
const entries = data ? Object.entries(data) : [];
return (
<div className="panel">
<h3 className="panel-title">Context</h3>
{entries.length === 0 ? (
<p className="context-empty">No context values yet</p>
) : (
<table className="context-table">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{entries.map(([key, value]) => (
<tr key={key}>
<td>{key}</td>
<td>{formatValue(value)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -0,0 +1,85 @@
import { useEffect, useRef } from "react";
import { usePipelineEvents } from "./hooks";
import type { PipelineEvent } from "./api";
interface EventLogProps {
pipelineId: string;
active: boolean;
}
function eventKey(event: PipelineEvent): string {
return Object.keys(event)[0] ?? "Unknown";
}
function eventCssClass(key: string): string {
// Convert PascalCase to kebab-case
return key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
function eventDetail(event: PipelineEvent): string {
const key = eventKey(event);
const data = (event as Record<string, Record<string, unknown>>)[key];
if (!data) return "";
switch (key) {
case "PipelineStarted":
return `${data.name}`;
case "PipelineCompleted":
return `${data.duration_ms}ms, ${data.artifact_count} artifacts`;
case "PipelineFailed":
return `${data.error} (${data.duration_ms}ms)`;
case "StageStarted":
return `${data.name} [${data.index}]`;
case "StageCompleted":
return `${data.name} [${data.index}] ${data.duration_ms}ms`;
case "StageFailed":
return `${data.name} [${data.index}]: ${data.error}${data.will_retry ? " (will retry)" : ""}`;
case "StageRetrying":
return `${data.name} [${data.index}] attempt ${data.attempt}, delay ${data.delay_ms}ms`;
case "ParallelStarted":
return `${data.branch_count} branches`;
case "ParallelBranchStarted":
return `${data.branch} [${data.index}]`;
case "ParallelBranchCompleted":
return `${data.branch} [${data.index}] ${data.duration_ms}ms ${data.success ? "ok" : "fail"}`;
case "ParallelCompleted":
return `${data.duration_ms}ms, ${data.success_count} ok, ${data.failure_count} fail`;
case "InterviewStarted":
return `"${data.question}" (${data.stage})`;
case "InterviewCompleted":
return `"${data.question}" -> "${data.answer}" (${data.duration_ms}ms)`;
case "InterviewTimeout":
return `"${data.question}" timed out (${data.duration_ms}ms)`;
case "CheckpointSaved":
return `node: ${data.node_id}`;
default:
return JSON.stringify(data);
}
}
export function EventLog({ pipelineId, active }: EventLogProps) {
const { events } = usePipelineEvents(pipelineId, active);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [events.length]);
return (
<div className="panel dashboard-full">
<h3 className="panel-title">Events</h3>
<div className="event-log">
{events.map((event, i) => {
const key = eventKey(event);
return (
<div key={i} className={`event-entry ${eventCssClass(key)}`}>
<span className="event-type">{key}</span>
<span className="event-detail">{eventDetail(event)}</span>
</div>
);
})}
<div ref={bottomRef} />
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { useState, useEffect } from "react";
import { getGraph } from "./api";
interface GraphViewProps {
pipelineId: string;
}
export function GraphView({ pipelineId }: GraphViewProps) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getGraph(pipelineId)
.then((data) => {
if (!cancelled) setSvg(data);
})
.catch((err) => {
if (!cancelled) setError(String(err));
});
return () => {
cancelled = true;
};
}, [pipelineId]);
return (
<div className="panel">
<h3 className="panel-title">Graph</h3>
<div className="graph-view">
{error && <p className="graph-error">{error}</p>}
{svg && <div dangerouslySetInnerHTML={{ __html: svg }} />}
{!svg && !error && <p className="graph-error">Loading graph...</p>}
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { useCallback } from "react";
import { getPipelineStatus, type PipelineStatusResponse } from "./api";
import { usePolling } from "./hooks";
import { StatusBar } from "./StatusBar";
import { EventLog } from "./EventLog";
import { GraphView } from "./GraphView";
import { ContextView } from "./ContextView";
import { CheckpointView } from "./CheckpointView";
import { QuestionPanel } from "./QuestionPanel";
interface PipelineDashboardProps {
pipelineId: string;
onBack: () => void;
}
export function PipelineDashboard({ pipelineId, onBack }: PipelineDashboardProps) {
const fetcher = useCallback(() => getPipelineStatus(pipelineId), [pipelineId]);
const { data: status } = usePolling<PipelineStatusResponse>(fetcher, 1000, true);
const pipelineStatus = status?.status ?? "running";
const active = pipelineStatus === "running";
return (
<div>
<button className="back-link" onClick={onBack}>
&larr; New Pipeline
</button>
<StatusBar id={pipelineId} status={pipelineStatus} error={status?.error} />
<QuestionPanel pipelineId={pipelineId} active={active} />
<div className="dashboard">
<EventLog pipelineId={pipelineId} active={active} />
<GraphView pipelineId={pipelineId} />
<ContextView pipelineId={pipelineId} active={active} />
<CheckpointView pipelineId={pipelineId} active={active} />
</div>
</div>
);
}

View file

@ -0,0 +1,125 @@
import { useState, useCallback } from "react";
import { getQuestions, submitAnswer, type ApiQuestion } from "./api";
import { usePolling } from "./hooks";
interface QuestionPanelProps {
pipelineId: string;
active: boolean;
}
function QuestionCard({
pipelineId,
question,
}: {
pipelineId: string;
question: ApiQuestion;
}) {
const [value, setValue] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit(answer: string) {
setSubmitting(true);
try {
await submitAnswer(pipelineId, question.id, answer);
setValue("");
} catch {
// question will remain visible for retry
} finally {
setSubmitting(false);
}
}
if (question.question_type === "YesNo") {
return (
<div className="question-card">
<div className="question-type">{question.question_type}</div>
<div className="question-text">{question.text}</div>
<div className="answer-row">
<button
className="btn-yes-no"
disabled={submitting}
onClick={() => submit("Yes")}
>
Yes
</button>
<button
className="btn-yes-no"
disabled={submitting}
onClick={() => submit("No")}
>
No
</button>
</div>
</div>
);
}
if (question.question_type === "Confirmation") {
return (
<div className="question-card">
<div className="question-type">{question.question_type}</div>
<div className="question-text">{question.text}</div>
<div className="answer-row">
<button
className="btn-answer"
disabled={submitting}
onClick={() => submit("Yes")}
>
Confirm
</button>
<button
className="btn-yes-no"
disabled={submitting}
onClick={() => submit("No")}
>
Decline
</button>
</div>
</div>
);
}
// Freeform and MultipleChoice (fallback to text input)
return (
<div className="question-card">
<div className="question-type">{question.question_type}</div>
<div className="question-text">{question.text}</div>
<div className="answer-row">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Type your answer..."
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) submit(value);
}}
/>
<button
className="btn-answer"
disabled={submitting || !value.trim()}
onClick={() => submit(value)}
>
Send
</button>
</div>
</div>
);
}
export function QuestionPanel({ pipelineId, active }: QuestionPanelProps) {
const fetcher = useCallback(() => getQuestions(pipelineId), [pipelineId]);
const { data: questions } = usePolling<ApiQuestion[]>(fetcher, 1000, active);
if (!questions || questions.length === 0) {
return null;
}
return (
<div className="panel question-panel dashboard-full">
<h3 className="panel-title">Questions</h3>
{questions.map((q) => (
<QuestionCard key={q.id} pipelineId={pipelineId} question={q} />
))}
</div>
);
}

View file

@ -0,0 +1,30 @@
import { cancelPipeline, type PipelineStatus } from "./api";
interface StatusBarProps {
id: string;
status: PipelineStatus;
error?: string;
}
export function StatusBar({ id, status, error }: StatusBarProps) {
async function handleCancel() {
try {
await cancelPipeline(id);
} catch {
// status will update via polling
}
}
return (
<div className="status-bar">
<span className={`status-badge ${status}`}>{status}</span>
<span className="pipeline-id">{id}</span>
{error && <span className="error-msg">{error}</span>}
{status === "running" && (
<button className="btn-danger" onClick={handleCancel}>
Cancel
</button>
)}
</div>
);
}

View file

@ -0,0 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<App />
</StrictMode>
);
if (import.meta.hot) {
const root = (import.meta.hot.data.root ??= createRoot(elem));
root.render(app);
} else {
createRoot(elem).render(app);
}

View file

@ -0,0 +1,74 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { eventsUrl, type PipelineEvent } from "./api";
export function usePipelineEvents(id: string, active: boolean) {
const [events, setEvents] = useState<PipelineEvent[]>([]);
const sourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!active) {
sourceRef.current?.close();
sourceRef.current = null;
return;
}
const es = new EventSource(eventsUrl(id));
sourceRef.current = es;
es.onmessage = (msg) => {
const event = JSON.parse(msg.data) as PipelineEvent;
setEvents((prev) => [...prev, event]);
};
es.onerror = () => {
es.close();
sourceRef.current = null;
};
return () => {
es.close();
sourceRef.current = null;
};
}, [id, active]);
const clear = useCallback(() => setEvents([]), []);
return { events, clear };
}
export function usePolling<T>(
fetcher: () => Promise<T>,
intervalMs: number,
active: boolean,
) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!active) return;
let cancelled = false;
const poll = async () => {
try {
const result = await fetcher();
if (!cancelled) {
setData(result);
setError(null);
}
} catch (e) {
if (!cancelled) setError(String(e));
}
};
poll();
const timer = setInterval(poll, intervalMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [fetcher, intervalMs, active]);
return { data, error };
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Attractor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,34 @@
import { serve } from "bun";
import index from "./index.html";
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3000";
function proxyToBackend(req: Request): Promise<Response> {
const url = new URL(req.url);
const backendPath = url.pathname.replace(/^\/api/, "");
const target = `${BACKEND_URL}${backendPath}${url.search}`;
return fetch(target, {
method: req.method,
headers: req.headers,
body: req.body,
// @ts-expect-error Bun supports duplex on fetch
duplex: "half",
});
}
const server = serve({
port: 5173,
routes: {
"/api/*": proxyToBackend,
"/*": index,
},
development: process.env.NODE_ENV !== "production" && {
hmr: true,
console: true,
},
});
console.log(`Server running at ${server.url}`);

View file

@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}