feat(dashboard): add client editing with PUT endpoint and edit modal
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

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.
This commit is contained in:
2026-03-02 14:56:19 -05:00
parent db5824f0fb
commit 4e53b05126
3 changed files with 188 additions and 4 deletions

View File

@@ -17,6 +17,14 @@ pub(super) struct CreateClientRequest {
pub(super) client_id: Option<String>, pub(super) client_id: Option<String>,
} }
#[derive(Deserialize)]
pub(super) struct UpdateClientPayload {
pub(super) name: Option<String>,
pub(super) description: Option<String>,
pub(super) is_active: Option<bool>,
pub(super) rate_limit_per_minute: Option<i64>,
}
pub(super) async fn handle_get_clients(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> { pub(super) async fn handle_get_clients(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;
@@ -25,11 +33,13 @@ pub(super) async fn handle_get_clients(State(state): State<DashboardState>) -> J
SELECT SELECT
client_id as id, client_id as id,
name, name,
description,
created_at, created_at,
total_requests, total_requests,
total_tokens, total_tokens,
total_cost, total_cost,
is_active is_active,
rate_limit_per_minute
FROM clients FROM clients
ORDER BY created_at DESC ORDER BY created_at DESC
"#, "#,
@@ -45,11 +55,13 @@ pub(super) async fn handle_get_clients(State(state): State<DashboardState>) -> J
serde_json::json!({ serde_json::json!({
"id": row.get::<String, _>("id"), "id": row.get::<String, _>("id"),
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()), "name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()),
"description": row.get::<Option<String>, _>("description"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"), "created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"requests_count": row.get::<i64, _>("total_requests"), "requests_count": row.get::<i64, _>("total_requests"),
"total_tokens": row.get::<i64, _>("total_tokens"), "total_tokens": row.get::<i64, _>("total_tokens"),
"total_cost": row.get::<f64, _>("total_cost"), "total_cost": row.get::<f64, _>("total_cost"),
"status": if row.get::<bool, _>("is_active") { "active" } else { "inactive" }, "status": if row.get::<bool, _>("is_active") { "active" } else { "inactive" },
"rate_limit_per_minute": row.get::<Option<i64>, _>("rate_limit_per_minute"),
}) })
}) })
.collect(); .collect();
@@ -110,7 +122,9 @@ pub(super) async fn handle_get_client(
SELECT SELECT
c.client_id as id, c.client_id as id,
c.name, c.name,
c.description,
c.is_active, c.is_active,
c.rate_limit_per_minute,
c.created_at, c.created_at,
COALESCE(c.total_tokens, 0) as total_tokens, COALESCE(c.total_tokens, 0) as total_tokens,
COALESCE(c.total_cost, 0.0) as total_cost, 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!({ Ok(Some(row)) => Json(ApiResponse::success(serde_json::json!({
"id": row.get::<String, _>("id"), "id": row.get::<String, _>("id"),
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()), "name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()),
"description": row.get::<Option<String>, _>("description"),
"is_active": row.get::<bool, _>("is_active"), "is_active": row.get::<bool, _>("is_active"),
"rate_limit_per_minute": row.get::<Option<i64>, _>("rate_limit_per_minute"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"), "created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"total_tokens": row.get::<i64, _>("total_tokens"), "total_tokens": row.get::<i64, _>("total_tokens"),
"total_cost": row.get::<f64, _>("total_cost"), "total_cost": row.get::<f64, _>("total_cost"),
@@ -146,6 +162,98 @@ pub(super) async fn handle_get_client(
} }
} }
pub(super) async fn handle_update_client(
State(state): State<DashboardState>,
Path(id): Path<String>,
Json(payload): Json<UpdateClientPayload>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool;
// Build dynamic UPDATE query from provided fields
let mut sets = Vec::new();
let mut binds: Vec<String> = 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::<String, _>("id"),
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()),
"description": row.get::<Option<String>, _>("description"),
"is_active": row.get::<bool, _>("is_active"),
"rate_limit_per_minute": row.get::<Option<i64>, _>("rate_limit_per_minute"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"total_requests": row.get::<i64, _>("total_requests"),
"total_tokens": row.get::<i64, _>("total_tokens"),
"total_cost": row.get::<f64, _>("total_cost"),
"status": if row.get::<bool, _>("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( pub(super) async fn handle_delete_client(
State(state): State<DashboardState>, State(state): State<DashboardState>,
Path(id): Path<String>, Path(id): Path<String>,

View File

@@ -82,7 +82,9 @@ pub fn router(state: AppState) -> Router {
) )
.route( .route(
"/api/clients/{id}", "/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/clients/{id}/usage", get(clients::handle_client_usage))
.route("/api/providers", get(providers::handle_get_providers)) .route("/api/providers", get(providers::handle_get_providers))

View File

@@ -189,8 +189,82 @@ class ClientsPage {
} }
} }
editClient(id) { async editClient(id) {
window.authManager.showToast('Edit client not implemented yet', 'info'); 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 = `
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Edit Client: ${client.id}</h3>
<button class="modal-close" onclick="this.closest('.modal').remove()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="form-control">
<label for="edit-client-name">Display Name</label>
<input type="text" id="edit-client-name" value="${client.name || ''}" placeholder="e.g. My Coding Assistant">
</div>
<div class="form-control">
<label for="edit-client-description">Description</label>
<textarea id="edit-client-description" rows="3" placeholder="Optional description">${client.description || ''}</textarea>
</div>
<div class="form-control">
<label for="edit-client-rate-limit">Rate Limit (requests/minute)</label>
<input type="number" id="edit-client-rate-limit" min="0" value="${client.rate_limit_per_minute || ''}" placeholder="Leave empty for unlimited">
</div>
<div class="form-control">
<label class="toggle-label">
<input type="checkbox" id="edit-client-active" ${client.is_active ? 'checked' : ''}>
<span>Active</span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
<button class="btn btn-primary" id="confirm-edit-client">Save Changes</button>
</div>
</div>
`;
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');
}
};
} }
} }