mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
b4f65e2555
commit
366643d6ef
20 changed files with 845 additions and 0 deletions
34
attractor-web/.gitignore
vendored
Normal file
34
attractor-web/.gitignore
vendored
Normal 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
111
attractor-web/CLAUDE.md
Normal 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
21
attractor-web/README.md
Normal 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
17
attractor-web/bun-env.d.ts
vendored
Normal 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
39
attractor-web/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
2
attractor-web/bunfig.toml
Normal file
2
attractor-web/bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[serve.static]
|
||||
env = "BUN_PUBLIC_*"
|
||||
20
attractor-web/package.json
Normal file
20
attractor-web/package.json
Normal 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
27
attractor-web/src/App.tsx
Normal 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;
|
||||
39
attractor-web/src/CheckpointView.tsx
Normal file
39
attractor-web/src/CheckpointView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
attractor-web/src/ContextView.tsx
Normal file
46
attractor-web/src/ContextView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
attractor-web/src/EventLog.tsx
Normal file
85
attractor-web/src/EventLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
attractor-web/src/GraphView.tsx
Normal file
38
attractor-web/src/GraphView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
attractor-web/src/PipelineDashboard.tsx
Normal file
38
attractor-web/src/PipelineDashboard.tsx
Normal 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}>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
125
attractor-web/src/QuestionPanel.tsx
Normal file
125
attractor-web/src/QuestionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
attractor-web/src/StatusBar.tsx
Normal file
30
attractor-web/src/StatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
attractor-web/src/frontend.tsx
Normal file
17
attractor-web/src/frontend.tsx
Normal 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);
|
||||
}
|
||||
74
attractor-web/src/hooks.ts
Normal file
74
attractor-web/src/hooks.ts
Normal 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 };
|
||||
}
|
||||
12
attractor-web/src/index.html
Normal file
12
attractor-web/src/index.html
Normal 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>
|
||||
34
attractor-web/src/index.ts
Normal file
34
attractor-web/src/index.ts
Normal 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}`);
|
||||
36
attractor-web/tsconfig.json
Normal file
36
attractor-web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue