From 4e53b05126be299ef66437100d08479a21210e34 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 2 Mar 2026 14:56:19 -0500 Subject: [PATCH] feat(dashboard): add client editing with PUT endpoint and edit modal Add PUT /api/clients/{id} with dynamic UPDATE for name, description, is_active, and rate_limit_per_minute. Expose description and rate_limit_per_minute in client list/detail responses. Replace the frontend editClient stub with a modal that fetches, edits, and saves client data. --- src/dashboard/clients.rs | 110 ++++++++++++++++++++++++++++++++++++- src/dashboard/mod.rs | 4 +- static/js/pages/clients.js | 78 +++++++++++++++++++++++++- 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/src/dashboard/clients.rs b/src/dashboard/clients.rs index 86c4450f..c9d16cc2 100644 --- a/src/dashboard/clients.rs +++ b/src/dashboard/clients.rs @@ -17,6 +17,14 @@ pub(super) struct CreateClientRequest { pub(super) client_id: Option, } +#[derive(Deserialize)] +pub(super) struct UpdateClientPayload { + pub(super) name: Option, + pub(super) description: Option, + pub(super) is_active: Option, + pub(super) rate_limit_per_minute: Option, +} + pub(super) async fn handle_get_clients(State(state): State) -> Json> { let pool = &state.app_state.db_pool; @@ -25,11 +33,13 @@ pub(super) async fn handle_get_clients(State(state): State) -> J SELECT client_id as id, name, + description, created_at, total_requests, total_tokens, total_cost, - is_active + is_active, + rate_limit_per_minute FROM clients ORDER BY created_at DESC "#, @@ -45,11 +55,13 @@ pub(super) async fn handle_get_clients(State(state): State) -> J serde_json::json!({ "id": row.get::("id"), "name": row.get::, _>("name").unwrap_or_else(|| "Unnamed".to_string()), + "description": row.get::, _>("description"), "created_at": row.get::, _>("created_at"), "requests_count": row.get::("total_requests"), "total_tokens": row.get::("total_tokens"), "total_cost": row.get::("total_cost"), "status": if row.get::("is_active") { "active" } else { "inactive" }, + "rate_limit_per_minute": row.get::, _>("rate_limit_per_minute"), }) }) .collect(); @@ -110,7 +122,9 @@ pub(super) async fn handle_get_client( SELECT c.client_id as id, c.name, + c.description, c.is_active, + c.rate_limit_per_minute, c.created_at, COALESCE(c.total_tokens, 0) as total_tokens, COALESCE(c.total_cost, 0.0) as total_cost, @@ -130,7 +144,9 @@ pub(super) async fn handle_get_client( Ok(Some(row)) => Json(ApiResponse::success(serde_json::json!({ "id": row.get::("id"), "name": row.get::, _>("name").unwrap_or_else(|| "Unnamed".to_string()), + "description": row.get::, _>("description"), "is_active": row.get::("is_active"), + "rate_limit_per_minute": row.get::, _>("rate_limit_per_minute"), "created_at": row.get::, _>("created_at"), "total_tokens": row.get::("total_tokens"), "total_cost": row.get::("total_cost"), @@ -146,6 +162,98 @@ pub(super) async fn handle_get_client( } } +pub(super) async fn handle_update_client( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Json> { + let pool = &state.app_state.db_pool; + + // Build dynamic UPDATE query from provided fields + let mut sets = Vec::new(); + let mut binds: Vec = Vec::new(); + + if let Some(ref name) = payload.name { + sets.push("name = ?"); + binds.push(name.clone()); + } + if let Some(ref desc) = payload.description { + sets.push("description = ?"); + binds.push(desc.clone()); + } + if payload.is_active.is_some() { + sets.push("is_active = ?"); + } + if payload.rate_limit_per_minute.is_some() { + sets.push("rate_limit_per_minute = ?"); + } + + if sets.is_empty() { + return Json(ApiResponse::error("No fields to update".to_string())); + } + + // Always update updated_at + sets.push("updated_at = CURRENT_TIMESTAMP"); + + let sql = format!("UPDATE clients SET {} WHERE client_id = ?", sets.join(", ")); + let mut query = sqlx::query(&sql); + + // Bind in the same order as sets + for b in &binds { + query = query.bind(b); + } + if let Some(active) = payload.is_active { + query = query.bind(active); + } + if let Some(rate) = payload.rate_limit_per_minute { + query = query.bind(rate); + } + query = query.bind(&id); + + match query.execute(pool).await { + Ok(result) => { + if result.rows_affected() == 0 { + return Json(ApiResponse::error(format!("Client '{}' not found", id))); + } + // Return the updated client + let row = sqlx::query( + r#" + SELECT client_id as id, name, description, is_active, rate_limit_per_minute, + created_at, total_requests, total_tokens, total_cost + FROM clients WHERE client_id = ? + "#, + ) + .bind(&id) + .fetch_one(pool) + .await; + + match row { + Ok(row) => Json(ApiResponse::success(serde_json::json!({ + "id": row.get::("id"), + "name": row.get::, _>("name").unwrap_or_else(|| "Unnamed".to_string()), + "description": row.get::, _>("description"), + "is_active": row.get::("is_active"), + "rate_limit_per_minute": row.get::, _>("rate_limit_per_minute"), + "created_at": row.get::, _>("created_at"), + "total_requests": row.get::("total_requests"), + "total_tokens": row.get::("total_tokens"), + "total_cost": row.get::("total_cost"), + "status": if row.get::("is_active") { "active" } else { "inactive" }, + }))), + Err(e) => { + warn!("Failed to fetch updated client: {}", e); + // Update succeeded but fetch failed — still report success + Json(ApiResponse::success(serde_json::json!({ "message": "Client updated" }))) + } + } + } + Err(e) => { + warn!("Failed to update client: {}", e); + Json(ApiResponse::error(format!("Failed to update client: {}", e))) + } + } +} + pub(super) async fn handle_delete_client( State(state): State, Path(id): Path, diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index bd77aa79..95d1672d 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -82,7 +82,9 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/clients/{id}", - get(clients::handle_get_client).delete(clients::handle_delete_client), + get(clients::handle_get_client) + .put(clients::handle_update_client) + .delete(clients::handle_delete_client), ) .route("/api/clients/{id}/usage", get(clients::handle_client_usage)) .route("/api/providers", get(providers::handle_get_providers)) diff --git a/static/js/pages/clients.js b/static/js/pages/clients.js index 51552052..26d2a35c 100644 --- a/static/js/pages/clients.js +++ b/static/js/pages/clients.js @@ -189,8 +189,82 @@ class ClientsPage { } } - editClient(id) { - window.authManager.showToast('Edit client not implemented yet', 'info'); + async editClient(id) { + try { + const client = await window.api.get(`/clients/${id}`); + this.showEditClientModal(client); + } catch (error) { + window.authManager.showToast(`Failed to load client: ${error.message}`, 'error'); + } + } + + showEditClientModal(client) { + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + modal.querySelector('#confirm-edit-client').onclick = async () => { + const name = modal.querySelector('#edit-client-name').value.trim(); + const description = modal.querySelector('#edit-client-description').value.trim(); + const rateLimitVal = modal.querySelector('#edit-client-rate-limit').value; + const isActive = modal.querySelector('#edit-client-active').checked; + + if (!name) { + window.authManager.showToast('Name is required', 'error'); + return; + } + + const payload = { + name, + description: description || null, + is_active: isActive, + rate_limit_per_minute: rateLimitVal ? parseInt(rateLimitVal, 10) : null, + }; + + try { + await window.api.put(`/clients/${client.id}`, payload); + window.authManager.showToast(`Client "${name}" updated`, 'success'); + modal.remove(); + this.loadClients(); + } catch (error) { + window.authManager.showToast(error.message, 'error'); + } + }; } }