From beb84ade9f5d8d128b07f8cb9b0d1f2cf2bcb211 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 15 Apr 2026 18:02:47 +0000 Subject: [PATCH] huskies: merge 567_story_gateway_ui_project_management_add_and_remove_projects --- frontend/src/api/gateway.ts | 16 ++ frontend/src/components/GatewayPanel.tsx | 168 +++++++++++++++++++ server/src/gateway.rs | 195 +++++++++++++++++++---- 3 files changed, 352 insertions(+), 27 deletions(-) diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts index 6fdf3850..408cf233 100644 --- a/frontend/src/api/gateway.ts +++ b/frontend/src/api/gateway.ts @@ -86,4 +86,20 @@ export const gatewayApi = { getGatewayInfo(): Promise { return gatewayRequest("/api/gateway"); }, + + /// Add a new project to the gateway config. + addProject(name: string, url: string): Promise { + return gatewayRequest("/api/gateway/projects", { + method: "POST", + body: JSON.stringify({ name, url }), + }); + }, + + /// Remove a project from the gateway config. + removeProject(name: string): Promise { + return gatewayRequest( + `/api/gateway/projects/${encodeURIComponent(name)}`, + { method: "DELETE" }, + ); + }, }; diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx index 6d08bdef..96e77f1e 100644 --- a/frontend/src/components/GatewayPanel.tsx +++ b/frontend/src/components/GatewayPanel.tsx @@ -186,6 +186,11 @@ export function GatewayPanel() { const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); + // Add-project form state + const [newProjectName, setNewProjectName] = useState(""); + const [newProjectUrl, setNewProjectUrl] = useState(""); + const [addingProject, setAddingProject] = useState(false); + useEffect(() => { gatewayApi .listAgents() @@ -234,6 +239,37 @@ export function GatewayPanel() { [], ); + const handleAddProject = useCallback(async () => { + const name = newProjectName.trim(); + const url = newProjectUrl.trim(); + if (!name || !url) return; + setAddingProject(true); + setError(null); + try { + const created = await gatewayApi.addProject(name, url); + setProjects((prev) => [...prev, created]); + setNewProjectName(""); + setNewProjectUrl(""); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setAddingProject(false); + } + }, [newProjectName, newProjectUrl]); + + const handleRemoveProject = useCallback(async (name: string) => { + if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) { + return; + } + setError(null); + try { + await gatewayApi.removeProject(name); + setProjects((prev) => prev.filter((p) => p.name !== name)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + return (
+ {/* Project management */} +
+

+ Projects{" "} + {projects.length > 0 && ( + + ({projects.length}) + + )} +

+ + {/* Existing projects list */} + {projects.map((p) => ( +
+
+
{p.name}
+
{p.url}
+
+ +
+ ))} + + {/* Add project form */} +
+
+
+ Name +
+ setNewProjectName(e.target.value)} + style={{ + width: "100%", + padding: "6px 10px", + borderRadius: "4px", + border: "1px solid #30363d", + background: "#0d1117", + color: "#e6edf3", + fontSize: "0.85em", + }} + /> +
+
+
+ Container URL +
+ setNewProjectUrl(e.target.value)} + style={{ + width: "100%", + padding: "6px 10px", + borderRadius: "4px", + border: "1px solid #30363d", + background: "#0d1117", + color: "#e6edf3", + fontSize: "0.85em", + }} + /> +
+ +
+
+ {error && (
>>, /// The currently active project name. pub active_project: Arc>, /// HTTP client for proxying requests to project containers. @@ -126,6 +126,21 @@ fn load_agents(config_dir: &Path) -> Vec { } } +/// Persist the current projects map to `/projects.toml`. +/// Silently ignores write errors or skips when `config_dir` is empty. +async fn save_config(projects: &BTreeMap, config_dir: &Path) { + if config_dir.as_os_str().is_empty() { + return; + } + let path = config_dir.join("projects.toml"); + let config = GatewayConfig { + projects: projects.clone(), + }; + if let Ok(data) = toml::to_string_pretty(&config) { + let _ = tokio::fs::write(&path, data).await; + } +} + /// Persist the current agent list to `/gateway_agents.json`. /// Silently ignores write errors (e.g. read-only filesystem or empty path). async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) { @@ -151,7 +166,7 @@ impl GatewayState { let first = config.projects.keys().next().unwrap().clone(); let agents = load_agents(&config_dir); Ok(Self { - config, + projects: Arc::new(RwLock::new(config.projects)), active_project: Arc::new(RwLock::new(first)), client: Client::new(), joined_agents: Arc::new(RwLock::new(agents)), @@ -165,8 +180,9 @@ impl GatewayState { /// Get the URL of the currently active project. async fn active_url(&self) -> Result { let name = self.active_project.read().await.clone(); - self.config - .projects + self.projects + .read() + .await .get(&name) .map(|p| p.url.clone()) .ok_or_else(|| format!("active project '{name}' not found in config")) @@ -485,27 +501,30 @@ async fn handle_switch_project(params: &Value, state: &GatewayState) -> JsonRpcR return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into()); } - if !state.config.projects.contains_key(project) { - let available: Vec<&str> = state.config.projects.keys().map(|s| s.as_str()).collect(); - return JsonRpcResponse::error( - None, - -32602, - format!( - "unknown project '{project}'. Available: {}", - available.join(", ") - ), - ); - } + let url = { + let projects = state.projects.read().await; + if !projects.contains_key(project) { + let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect(); + return JsonRpcResponse::error( + None, + -32602, + format!( + "unknown project '{project}'. Available: {}", + available.join(", ") + ), + ); + } + projects[project].url.clone() + }; *state.active_project.write().await = project.to_string(); - let url = &state.config.projects[project].url; JsonRpcResponse::success( None, json!({ "content": [{ "type": "text", - "text": format!("Switched to project '{project}' ({})", url) + "text": format!("Switched to project '{project}' ({url})") }] }), ) @@ -562,8 +581,15 @@ async fn handle_gateway_status(state: &GatewayState) -> JsonRpcResponse { async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse { let mut results = BTreeMap::new(); - for (name, entry) in &state.config.projects { - let health_url = format!("{}/health", entry.url.trim_end_matches('/')); + let project_entries: Vec<(String, String)> = state + .projects + .read() + .await + .iter() + .map(|(n, e)| (n.clone(), e.url.clone())) + .collect(); + for (name, url) in &project_entries { + let health_url = format!("{}/health", url.trim_end_matches('/')); let status = match state.client.get(&health_url).send().await { Ok(resp) => { if resp.status().is_success() { @@ -749,7 +775,7 @@ pub async fn gateway_assign_agent_handler( .and_then(|p| if p.is_empty() { None } else { Some(p) }); if let Some(ref p) = project - && !state.config.projects.contains_key(p.as_str()) + && !state.projects.read().await.contains_key(p.as_str()) { return Response::builder() .status(StatusCode::BAD_REQUEST) @@ -797,8 +823,15 @@ pub async fn gateway_health_handler(state: Data<&Arc>) -> Response let mut all_healthy = true; let mut statuses = BTreeMap::new(); - for (name, entry) in &state.config.projects { - let health_url = format!("{}/health", entry.url.trim_end_matches('/')); + let project_entries: Vec<(String, String)> = state + .projects + .read() + .await + .iter() + .map(|(n, e)| (n.clone(), e.url.clone())) + .collect(); + for (name, url) in &project_entries { + let health_url = format!("{}/health", url.trim_end_matches('/')); let healthy = match state.client.get(&health_url).send().await { Ok(resp) => resp.status().is_success(), Err(_) => false, @@ -1000,8 +1033,9 @@ pub async fn gateway_index_handler() -> Response { pub async fn gateway_api_handler(state: Data<&Arc>) -> Response { let active = state.active_project.read().await.clone(); let projects: Vec = state - .config .projects + .read() + .await .iter() .map(|(name, entry)| { json!({ @@ -1065,6 +1099,104 @@ pub async fn gateway_switch_handler( )) } +// ── Project management API ─────────────────────────────────────────── + +/// Request body for adding a new project. +#[derive(Deserialize)] +struct AddProjectRequest { + name: String, + url: String, +} + +/// `POST /api/gateway/projects` — add a new project to the gateway config. +/// +/// Expects JSON `{ "name": "...", "url": "..." }`. Returns the created project +/// or 409 Conflict if a project with the same name already exists. +#[handler] +pub async fn gateway_add_project_handler( + state: Data<&Arc>, + body: Json, +) -> Response { + let name = body.0.name.trim().to_string(); + let url = body.0.url.trim().to_string(); + + if name.is_empty() { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from("project name must not be empty")); + } + if url.is_empty() { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from("project url must not be empty")); + } + + { + let mut projects = state.projects.write().await; + if projects.contains_key(&name) { + return Response::builder() + .status(StatusCode::CONFLICT) + .body(Body::from(format!("project '{name}' already exists"))); + } + projects.insert(name.clone(), ProjectEntry { url: url.clone() }); + } + + let snapshot = state.projects.read().await.clone(); + save_config(&snapshot, &state.config_dir).await; + + crate::slog!("[gateway] Added project '{name}' ({url})"); + let body_val = json!({ "name": name, "url": url }); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_vec(&body_val).unwrap_or_default(), + )) +} + +/// `DELETE /api/gateway/projects/:name` — remove a project from the gateway config. +/// +/// Returns 204 No Content on success. Returns 400 if this is the last project +/// (the gateway requires at least one project to remain configured). +#[handler] +pub async fn gateway_remove_project_handler( + PoemPath(name): PoemPath, + state: Data<&Arc>, +) -> Response { + let active = state.active_project.read().await.clone(); + + { + let mut projects = state.projects.write().await; + if !projects.contains_key(&name) { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from(format!("project '{name}' not found"))); + } + if projects.len() == 1 { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from("cannot remove the last project")); + } + projects.remove(&name); + } + + let snapshot = state.projects.read().await.clone(); + save_config(&snapshot, &state.config_dir).await; + + // If the removed project was active, switch to the first remaining. + if active == name { + let first = state.projects.read().await.keys().next().cloned(); + if let Some(new_active) = first { + *state.active_project.write().await = new_active; + } + } + + crate::slog!("[gateway] Removed project '{name}'"); + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) +} + // ── Bot configuration API ──────────────────────────────────────────── /// Request/response body for the bot configuration API. @@ -1173,7 +1305,7 @@ pub async fn gateway_bot_config_save_handler( if let Some(h) = handle.take() { h.abort(); } - let gateway_projects: Vec = state.config.projects.keys().cloned().collect(); + let gateway_projects: Vec = state.projects.read().await.keys().cloned().collect(); let new_handle = spawn_gateway_bot( &state.config_dir, Arc::clone(&state.active_project), @@ -1422,8 +1554,9 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { crate::slog!( "[gateway] Registered projects: {}", state_arc - .config .projects + .read() + .await .keys() .cloned() .collect::>() @@ -1437,7 +1570,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { } // Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory. - let gateway_projects: Vec = state_arc.config.projects.keys().cloned().collect(); + let gateway_projects: Vec = state_arc.projects.read().await.keys().cloned().collect(); let bot_abort = spawn_gateway_bot( &config_dir, Arc::clone(&state_arc.active_project), @@ -1451,6 +1584,14 @@ 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/projects", + poem::post(gateway_add_project_handler), + ) + .at( + "/api/gateway/projects/:name", + poem::delete(gateway_remove_project_handler), + ) .at( "/api/gateway/bot-config", poem::get(gateway_bot_config_get_handler).post(gateway_bot_config_save_handler),