Add GET /pipelines endpoint and pipeline list UI

Adds a list endpoint that returns all in-memory pipelines with their
status, and a "Recent Pipelines" section in the start form that polls
every 3s so users can navigate to any pipeline started since server launch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 13:39:28 -05:00
parent e0f33e072e
commit ffa92cd23d
4 changed files with 647 additions and 1 deletions

View file

@ -0,0 +1,76 @@
import { useState, useCallback, type FormEvent } from "react";
import { startPipeline, listPipelines, type PipelineStatusResponse } from "./api";
import { usePolling } from "./hooks";
const PLACEHOLDER = `digraph Example {
graph [goal="Run a simple pipeline"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}`;
interface StartFormProps {
onStart: (id: string) => void;
}
export function StartForm({ onStart }: StartFormProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetcher = useCallback(() => listPipelines(), []);
const { data: pipelines } = usePolling(fetcher, 3000, true);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const dotSource = new FormData(form).get("dot_source") as string;
if (!dotSource.trim()) return;
setSubmitting(true);
setError(null);
try {
const { id } = await startPipeline(dotSource);
onStart(id);
} catch (err) {
setError(String(err));
} finally {
setSubmitting(false);
}
}
return (
<form className="start-form" onSubmit={handleSubmit}>
<label htmlFor="dot-source">DOT Graph Source</label>
<textarea
id="dot-source"
name="dot_source"
placeholder={PLACEHOLDER}
defaultValue={PLACEHOLDER}
/>
<div className="form-row">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? "Starting..." : "Start Pipeline"}
</button>
{error && <span className="error">{error}</span>}
</div>
{pipelines && pipelines.length > 0 && (
<div className="pipeline-list">
<h3 className="pipeline-list-title">Recent Pipelines</h3>
{pipelines.map((p: PipelineStatusResponse) => (
<button
key={p.id}
type="button"
className="pipeline-list-item"
onClick={() => onStart(p.id)}
>
<span className="pipeline-list-id">{p.id.slice(0, 8)}</span>
<span className={`status-badge ${p.status}`}>{p.status}</span>
</button>
))}
</div>
)}
</form>
);
}

View file

@ -70,6 +70,12 @@ export type ContextSnapshot = Record<string, unknown>;
const API_BASE = "/api";
export async function listPipelines(): Promise<PipelineStatusResponse[]> {
const res = await fetch(`${API_BASE}/pipelines`);
if (!res.ok) throw new Error(`List failed: ${res.status}`);
return res.json() as Promise<PipelineStatusResponse[]>;
}
export async function startPipeline(dotSource: string): Promise<StartPipelineResponse> {
const res = await fetch(`${API_BASE}/pipelines`, {
method: "POST",

504
attractor-web/src/index.css Normal file
View file

@ -0,0 +1,504 @@
:root {
--bg: #0a0a0f;
--bg-surface: #12121a;
--bg-elevated: #1a1a24;
--text: #c8c8d0;
--text-dim: #6e6e7a;
--text-bright: #e8e8ef;
--accent: #7c9f35;
--accent-dim: #5a7328;
--border: #2a2a36;
--error: #d04040;
--warning: #c89020;
--success: #4a9f4a;
font-family: "Berkeley Mono", "JetBrains Mono", "Fira Code", monospace;
font-size: 14px;
line-height: 1.6;
color: var(--text);
background-color: var(--bg);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.app-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 24px;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
.app-header h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.app-header span {
color: var(--text-dim);
font-size: 12px;
}
/* Start Form */
.start-form {
max-width: 640px;
}
.start-form label {
display: block;
color: var(--text-dim);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.start-form textarea {
width: 100%;
min-height: 200px;
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
padding: 12px;
resize: vertical;
outline: none;
}
.start-form textarea:focus {
border-color: var(--accent);
}
.start-form textarea::placeholder {
color: var(--text-dim);
}
.start-form .form-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.start-form .error {
color: var(--error);
font-size: 12px;
}
button {
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.btn-primary {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 20px;
font-weight: 600;
letter-spacing: 0.04em;
}
.btn-primary:hover {
background: var(--accent-dim);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
background: none;
color: var(--error);
border: 1px solid var(--error);
padding: 6px 14px;
}
.btn-danger:hover {
background: var(--error);
color: #fff;
}
/* Status Bar */
.status-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 10px 14px;
background: var(--bg-surface);
border: 1px solid var(--border);
}
.status-badge {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 2px 10px;
border: 1px solid;
}
.status-badge.running {
color: var(--accent);
border-color: var(--accent);
}
.status-badge.completed {
color: var(--success);
border-color: var(--success);
}
.status-badge.failed {
color: var(--error);
border-color: var(--error);
}
.status-badge.cancelled {
color: var(--warning);
border-color: var(--warning);
}
.status-bar .pipeline-id {
color: var(--text-dim);
font-size: 12px;
flex: 1;
}
.status-bar .error-msg {
color: var(--error);
font-size: 12px;
}
/* Dashboard layout */
.dashboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.dashboard-full {
grid-column: 1 / -1;
}
/* Panel base */
.panel {
background: var(--bg-surface);
border: 1px solid var(--border);
padding: 14px;
overflow: hidden;
}
.panel-title {
margin: 0 0 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
}
/* Event Log */
.event-log {
max-height: 400px;
overflow-y: auto;
font-size: 12px;
}
.event-log .event-entry {
padding: 3px 0;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
}
.event-log .event-entry:last-child {
border-bottom: none;
}
.event-log .event-type {
color: var(--text-dim);
min-width: 180px;
flex-shrink: 0;
}
.event-log .event-detail {
color: var(--text);
word-break: break-word;
}
.event-entry.pipeline-started .event-type,
.event-entry.stage-started .event-type,
.event-entry.parallel-started .event-type {
color: var(--accent);
}
.event-entry.pipeline-completed .event-type,
.event-entry.stage-completed .event-type,
.event-entry.parallel-completed .event-type {
color: var(--success);
}
.event-entry.pipeline-failed .event-type,
.event-entry.stage-failed .event-type {
color: var(--error);
}
.event-entry.stage-retrying .event-type {
color: var(--warning);
}
.event-entry.interview-started .event-type,
.event-entry.interview-completed .event-type {
color: #8888cc;
}
/* Graph View */
.graph-view svg {
width: 100%;
height: auto;
max-height: 500px;
}
.graph-view .graph-error {
color: var(--text-dim);
font-size: 12px;
}
/* Context View */
.context-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.context-table th {
text-align: left;
color: var(--text-dim);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 4px 8px 4px 0;
border-bottom: 1px solid var(--border);
}
.context-table td {
padding: 4px 8px 4px 0;
border-bottom: 1px solid var(--border);
word-break: break-word;
}
.context-table td:first-child {
color: var(--accent);
white-space: nowrap;
}
.context-empty {
color: var(--text-dim);
font-size: 12px;
}
/* Checkpoint View */
.checkpoint-view pre {
margin: 0;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
}
.checkpoint-view .node-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.checkpoint-view .node-tag {
font-size: 11px;
padding: 2px 8px;
border: 1px solid var(--border);
color: var(--text-dim);
}
.checkpoint-view .node-tag.current {
border-color: var(--accent);
color: var(--accent);
}
.checkpoint-view .node-tag.completed {
border-color: var(--success);
color: var(--success);
}
/* Question Panel */
.question-panel {
border-left: 3px solid var(--accent);
}
.question-card {
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.question-card:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.question-card .question-type {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin-bottom: 4px;
}
.question-card .question-text {
color: var(--text-bright);
margin-bottom: 8px;
font-size: 13px;
}
.question-card .answer-row {
display: flex;
gap: 8px;
}
.question-card input[type="text"] {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 12px;
padding: 6px 10px;
outline: none;
}
.question-card input[type="text"]:focus {
border-color: var(--accent);
}
.question-card .btn-answer {
background: var(--accent);
color: #fff;
border: none;
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.question-card .btn-yes-no {
background: none;
border: 1px solid var(--border);
color: var(--text);
padding: 6px 16px;
font-size: 12px;
cursor: pointer;
}
.question-card .btn-yes-no:hover {
border-color: var(--accent);
color: var(--accent);
}
.questions-empty {
color: var(--text-dim);
font-size: 12px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
}
/* Pipeline List */
.pipeline-list {
margin-top: 24px;
}
.pipeline-list-title {
margin: 0 0 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
}
.pipeline-list-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
margin-bottom: -1px;
}
.pipeline-list-item:hover {
background: var(--bg-elevated);
border-color: var(--accent);
z-index: 1;
position: relative;
}
.pipeline-list-id {
color: var(--text-dim);
font-size: 12px;
}
.back-link {
background: none;
border: none;
color: var(--text-dim);
font-family: inherit;
font-size: 12px;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.back-link:hover {
color: var(--text);
}

View file

@ -104,7 +104,7 @@ pub struct SubmitAnswerResponse {
/// Build the axum Router with all pipeline endpoints.
pub fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/pipelines", post(start_pipeline))
.route("/pipelines", get(list_pipelines).post(start_pipeline))
.route("/pipelines/{id}", get(get_pipeline_status))
.route("/pipelines/{id}/questions", get(get_questions))
.route(
@ -132,6 +132,19 @@ pub fn create_app_state(
})
}
async fn list_pipelines(State(state): State<Arc<AppState>>) -> Response {
let pipelines = state.pipelines.lock().expect("pipelines lock poisoned");
let items: Vec<PipelineStatusResponse> = pipelines
.iter()
.map(|(id, pipeline)| PipelineStatusResponse {
id: id.clone(),
status: pipeline.status.clone(),
error: pipeline.error.clone(),
})
.collect();
(StatusCode::OK, Json(items)).into_response()
}
async fn start_pipeline(
State(state): State<Arc<AppState>>,
Json(req): Json<StartPipelineRequest>,
@ -911,4 +924,51 @@ mod tests {
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn list_pipelines_returns_started_pipeline() {
let state = create_app_state(test_registry);
let app = build_router(Arc::clone(&state));
// List should be empty initially
let req = Request::builder()
.method("GET")
.uri("/pipelines")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body.as_array().unwrap().len(), 0);
// Start a pipeline
let req = Request::builder()
.method("POST")
.uri("/pipelines")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let pipeline_id = body["id"].as_str().unwrap().to_string();
// List should now contain one pipeline
let req = Request::builder()
.method("GET")
.uri("/pipelines")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let items = body.as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"].as_str().unwrap(), pipeline_id);
assert!(items[0]["status"].as_str().is_some());
}
}