mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
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:
parent
e0f33e072e
commit
ffa92cd23d
4 changed files with 647 additions and 1 deletions
76
attractor-web/src/StartForm.tsx
Normal file
76
attractor-web/src/StartForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
504
attractor-web/src/index.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue