diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts index ce30cf5e..e803ffda 100644 --- a/frontend/src/api/gateway.ts +++ b/frontend/src/api/gateway.ts @@ -24,6 +24,28 @@ export interface GatewayInfo { projects: GatewayProject[]; } +export interface PipelineItem { + story_id: string; + name: string; + stage: string; + agent?: { agent_name: string; model: string; status: string } | null; + blocked?: boolean; + retry_count?: number; + merge_failure?: string; +} + +export interface ProjectPipelineStatus { + active: PipelineItem[]; + backlog: { story_id: string; name: string }[]; + backlog_count: number; + error?: string; +} + +export interface AllProjectsPipeline { + active: string; + projects: Record; +} + export interface GenerateTokenResponse { token: string; } @@ -111,4 +133,17 @@ export const gatewayApi = { method: "POST", }); }, + + /// Fetch pipeline status from all registered projects. + getAllProjectsPipeline(): Promise { + return gatewayRequest("/api/gateway/pipeline"); + }, + + /// Switch the active project. + switchProject(project: string): Promise<{ ok: boolean; error?: string }> { + return gatewayRequest<{ ok: boolean; error?: string }>( + "/api/gateway/switch", + { method: "POST", body: JSON.stringify({ project }) }, + ); + }, }; diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx index d2430160..11bab076 100644 --- a/frontend/src/components/GatewayPanel.tsx +++ b/frontend/src/components/GatewayPanel.tsx @@ -1,13 +1,21 @@ /// Gateway management panel shown when huskies runs in `--gateway` mode. /// /// Provides: +/// - A cross-project pipeline status view showing active stories per project. +/// - Clicking a project card switches to it. /// - An "Add Agent" button that generates a one-time join token. /// - Instructions for running a build agent with the token. /// - A list of connected agents with per-agent status, project assignment, and "Remove" buttons. /// - Auto-refresh every 5 seconds so new agents and disconnections appear without a page reload. import * as React from "react"; -import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway"; +import { + gatewayApi, + type JoinedAgent, + type GatewayProject, + type AllProjectsPipeline, + type PipelineItem, +} from "../api/gateway"; const { useCallback, useEffect, useRef, useState } = React; @@ -40,6 +48,127 @@ const STATUS_LABELS: Record = { disconnected: "Disconnected", }; +const STAGE_COLORS: Record = { + current: "#3fb950", + qa: "#d2a679", + merge: "#79c0ff", + done: "#6e7681", +}; + +const STAGE_LABELS: Record = { + current: "In Progress", + qa: "QA", + merge: "Merging", + done: "Done", +}; + +/// A single story row inside a project pipeline card. +function StoryRow({ item }: { item: PipelineItem }) { + const color = STAGE_COLORS[item.stage] ?? "#8b949e"; + const label = STAGE_LABELS[item.stage] ?? item.stage; + + return ( +
+ + {label} + + + {item.name} + +
+ ); +} + +/// Pipeline status card for a single project. +function ProjectPipelineCard({ + name, + pipeline, + isActive, + onSwitch, +}: { + name: string; + pipeline: AllProjectsPipeline["projects"][string]; + isActive: boolean; + onSwitch: (name: string) => void; +}) { + const activeItems = pipeline.active ?? []; + const backlogCount = pipeline.backlog_count ?? 0; + const hasError = Boolean(pipeline.error); + + return ( +
onSwitch(name)} + style={{ + padding: "12px 16px", + background: "#161b22", + border: `1px solid ${isActive ? "#238636" : "#30363d"}`, + borderRadius: "8px", + marginBottom: "8px", + cursor: "pointer", + }} + > +
0 ? "8px" : 0, + }} + > + {name} + {isActive && ( + + active + + )} + + {backlogCount > 0 ? `${backlogCount} in backlog` : ""} + +
+ + {hasError ? ( +
{pipeline.error}
+ ) : activeItems.length === 0 ? ( +
No active stories
+ ) : ( +
+ {activeItems.map((item) => ( + + ))} +
+ )} +
+ ); +} + function TokenDisplay({ token }: { token: string }) { const [copied, setCopied] = useState(false); @@ -237,16 +366,18 @@ export function GatewayPanel() { const [token, setToken] = useState(null); const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); + const [pipeline, setPipeline] = useState(null); // Add-project form state const [newProjectName, setNewProjectName] = useState(""); const [newProjectUrl, setNewProjectUrl] = useState(""); const [addingProject, setAddingProject] = useState(false); - // Keep a stable ref to setAgents so the polling interval doesn't need to - // be recreated when the agents list changes. + // Keep stable refs so polling intervals don't recreate on state changes. const setAgentsRef = useRef(setAgents); setAgentsRef.current = setAgents; + const setPipelineRef = useRef(setPipeline); + setPipelineRef.current = setPipeline; useEffect(() => { // Initial load. @@ -258,16 +389,22 @@ export function GatewayPanel() { .getGatewayInfo() .then((info) => setProjects(info.projects)) .catch(() => setProjects([])); + gatewayApi + .getAllProjectsPipeline() + .then(setPipeline) + .catch(() => setPipeline(null)); - // Poll the agent list so the dashboard auto-updates when agents connect - // or disconnect. + // Poll so the dashboard auto-updates as agents connect/disconnect and + // stories move through pipelines. const timer = setInterval(() => { gatewayApi .listAgents() .then((updated) => setAgentsRef.current(updated)) - .catch(() => { - // Swallow poll errors to avoid spamming the error banner. - }); + .catch(() => {}); + gatewayApi + .getAllProjectsPipeline() + .then((updated) => setPipelineRef.current(updated)) + .catch(() => {}); }, POLL_INTERVAL_MS); return () => clearInterval(timer); @@ -328,6 +465,22 @@ export function GatewayPanel() { } }, [newProjectName, newProjectUrl]); + const handleSwitchProject = useCallback(async (name: string) => { + setError(null); + try { + const result = await gatewayApi.switchProject(name); + if (!result.ok) { + setError(result.error ?? "Failed to switch project"); + return; + } + // Refresh pipeline to reflect new active project. + const updated = await gatewayApi.getAllProjectsPipeline(); + setPipeline(updated); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + const handleRemoveProject = useCallback(async (name: string) => { if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) { return; @@ -359,6 +512,34 @@ export function GatewayPanel() { Manage build agents connected to this gateway.

+ {/* Cross-project pipeline status */} +
+

+ Pipeline Status +

+ {pipeline ? ( + Object.entries(pipeline.projects).map(([name, status]) => ( + + )) + ) : ( +

Loading pipeline status…

+ )} +
+ {/* Add Agent */}

String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } +/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects. +/// +/// Returns `{ "active": "", "projects": { "": { "active": [...], "backlog": [...], "backlog_count": N } | { "error": "..." } } }`. +#[handler] +pub async fn gateway_all_pipeline_handler(state: Data<&Arc>) -> Response { + let project_entries: Vec<(String, String)> = state + .projects + .read() + .await + .iter() + .map(|(n, e)| (n.clone(), e.url.clone())) + .collect(); + + let mut results: BTreeMap = BTreeMap::new(); + + for (name, url) in &project_entries { + let mcp_url = format!("{}/mcp", url.trim_end_matches('/')); + let rpc_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_pipeline_status", + "arguments": {} + } + }); + + let status = match state.client.post(&mcp_url).json(&rpc_body).send().await { + Ok(resp) => match resp.json::().await { + Ok(upstream) => { + // The tool result is a JSON string embedded in content[0].text. + if let Some(text) = upstream + .get("result") + .and_then(|r| r.get("content")) + .and_then(|c| c.get(0)) + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + { + serde_json::from_str(text) + .unwrap_or_else(|_| json!({ "error": "invalid pipeline json" })) + } else { + json!({ "error": "unexpected response shape" }) + } + } + Err(e) => json!({ "error": format!("invalid response: {e}") }), + }, + Err(e) => json!({ "error": format!("unreachable: {e}") }), + }; + + results.insert(name.clone(), status); + } + + let active = state.active_project.read().await.clone(); + let body = json!({ "active": active, "projects": results }); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) +} + /// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON. #[handler] pub async fn gateway_bot_config_get_handler(state: Data<&Arc>) -> Response { @@ -1622,6 +1682,10 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { .at("/bot-config", poem::get(gateway_bot_config_page_handler)) .at("/api/gateway", poem::get(gateway_api_handler)) .at("/api/gateway/switch", poem::post(gateway_switch_handler)) + .at( + "/api/gateway/pipeline", + poem::get(gateway_all_pipeline_handler), + ) .at( "/api/gateway/projects", poem::post(gateway_add_project_handler),