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.
This commit is contained in:
@@ -17,6 +17,14 @@ pub(super) struct CreateClientRequest {
|
||||
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>> {
|
||||
let pool = &state.app_state.db_pool;
|
||||
|
||||
@@ -25,11 +33,13 @@ pub(super) async fn handle_get_clients(State(state): State<DashboardState>) -> 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<DashboardState>) -> J
|
||||
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"),
|
||||
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
|
||||
"requests_count": 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" },
|
||||
"rate_limit_per_minute": row.get::<Option<i64>, _>("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::<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_tokens": row.get::<i64, _>("total_tokens"),
|
||||
"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(
|
||||
State(state): State<DashboardState>,
|
||||
Path(id): Path<String>,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = `
|
||||
<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');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user