1use crate::cli::{AppCommands, Cli};
2use crate::client::ApiClient;
3use crate::config::Config;
4use crate::error::{CliError, Result};
5use crate::output::{create_table_with_headers, format_output, print_success, print_warning, OutputFormat};
6use chrono::{DateTime, Utc};
7use dialoguer::Confirm;
8use serde::{Deserialize, Serialize};
9
10pub async fn execute(command: &AppCommands, cli: &Cli) -> Result<()> {
11 let output_format = cli.output.as_ref().map(|s| OutputFormat::from_str(s)).unwrap_or(OutputFormat::Table);
12
13 match command {
14 AppCommands::Create { name, default_db, allowed_dbs, max_users, storage_quota, max_connections, qps_limit } => {
15 create_app(name.clone(), default_db.clone(), allowed_dbs.clone(), *max_users, *storage_quota, *max_connections, *qps_limit, &output_format).await
16 }
17 AppCommands::List { status, page, page_size } => {
18 list_apps(status.clone(), *page, *page_size, &output_format).await
19 }
20 AppCommands::Show { app_id } => {
21 show_app(app_id.clone(), &output_format).await
22 }
23 AppCommands::Update { app_id, name, default_db, allowed_dbs, max_users, storage_quota, max_connections, qps_limit } => {
24 update_app(app_id.clone(), name.clone(), default_db.clone(), allowed_dbs.clone(), *max_users, *storage_quota, *max_connections, *qps_limit, &output_format).await
25 }
26 AppCommands::Enable { app_id } => {
27 enable_app(app_id.clone(), &output_format).await
28 }
29 AppCommands::Disable { app_id } => {
30 disable_app(app_id.clone(), &output_format).await
31 }
32 AppCommands::Delete { app_id, force } => {
33 delete_app(app_id.clone(), *force, &output_format).await
34 }
35 AppCommands::ResetToken { app_id } => {
36 reset_token(app_id.clone(), &output_format).await
37 }
38 AppCommands::Stats { app_id } => {
39 show_stats(app_id.clone(), &output_format).await
40 }
41 }
42}
43
44#[derive(Debug, Serialize, Deserialize)]
45pub struct CreateAppRequest {
46 pub app_name: String,
47 pub default_database: String,
48 pub allowed_databases: Vec<String>,
49 pub max_users: u32,
50 pub default_storage_quota_mb: u32,
51 pub default_max_connections: u32,
52 pub default_qps: u32,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct CreateAppResponse {
57 pub app_id: String,
58 pub app_name: String,
59 pub app_token: String,
60 pub default_database: String,
61 pub allowed_databases: Vec<String>,
62 pub max_users: u32,
63 pub status: String,
64 pub created_at: String,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct AppListResponse {
69 pub items: Vec<AppListItem>,
70 pub total: u32,
71 pub page: u32,
72 pub page_size: u32,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub struct AppListItem {
77 pub app_id: String,
78 pub app_name: String,
79 pub status: String,
80 pub user_count: u32,
81 pub created_at: String,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85pub struct AppDetail {
86 pub app_id: String,
87 pub app_name: String,
88 pub default_database: String,
89 pub allowed_databases: Vec<String>,
90 pub status: String,
91 pub max_users: u32,
92 pub default_storage_quota_mb: u32,
93 pub default_max_connections: u32,
94 pub default_qps: u32,
95 pub user_count: u32,
96 pub total_storage_mb: f64,
97 pub created_at: String,
98 pub updated_at: String,
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102pub struct UpdateAppRequest {
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub app_name: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub default_database: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub allowed_databases: Option<Vec<String>>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub max_users: Option<u32>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub default_storage_quota_mb: Option<u32>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub default_max_connections: Option<u32>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub default_qps: Option<u32>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub status: Option<String>,
119}
120
121#[derive(Debug, Serialize, Deserialize)]
122pub struct UpdateAppResponse {
123 pub app_id: String,
124 pub updated_fields: Vec<String>,
125 pub updated_at: String,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129pub struct DeleteAppResponse {
130 pub app_id: String,
131 pub deleted: bool,
132 pub affected_users: u32,
133 pub workspace_cleanup: String,
134}
135
136#[derive(Debug, Serialize, Deserialize)]
137pub struct ResetTokenResponse {
138 pub app_id: String,
139 pub app_token: String,
140 pub rotated_at: String,
141}
142
143#[derive(Debug, Serialize, Deserialize)]
144pub struct AppStats {
145 pub app_id: String,
146 pub user_count: u32,
147 pub active_user_count: u32,
148 pub total_storage_mb: f64,
149 pub total_connections: u32,
150 pub recent_users: Vec<RecentUser>,
151}
152
153#[derive(Debug, Serialize, Deserialize)]
154pub struct RecentUser {
155 pub user_uid: String,
156 pub label: Option<String>,
157 pub last_accessed_at: String,
158}
159
160pub async fn create_app(
161 name: String,
162 default_db: String,
163 allowed_dbs: String,
164 max_users: u32,
165 storage_quota: u32,
166 max_connections: u32,
167 qps_limit: u32,
168 output_format: &OutputFormat,
169) -> Result<()> {
170 let config = Config::load()?;
171 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
172
173 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
174
175 let allowed_databases: Vec<String> = allowed_dbs
176 .split(',')
177 .map(|s| s.trim().to_string())
178 .filter(|s| !s.is_empty())
179 .collect();
180
181 let request = CreateAppRequest {
182 app_name: name,
183 default_database: default_db,
184 allowed_databases,
185 max_users,
186 default_storage_quota_mb: storage_quota,
187 default_max_connections: max_connections,
188 default_qps: qps_limit,
189 };
190
191 let request_json = serde_json::to_value(&request)
192 .map_err(|e| CliError::SerializationError(format!("Failed to serialize request: {}", e)))?;
193 let response: CreateAppResponse = client.post("/api/v1/apps", &request_json).await?;
194
195 match output_format {
196 OutputFormat::Json | OutputFormat::Yaml => {
197 println!("{}", format_output(&response, *output_format)?);
198 }
199 OutputFormat::Table => {
200 print_success("App created successfully");
201 println!();
202 println!(" App ID: {}", response.app_id);
203 println!(" App Name: {}", response.app_name);
204 println!(" App Token: {}", response.app_token);
205 println!();
206 print_warning("⚠️ Save the app token - it won't be shown again!");
207 }
208 }
209
210 Ok(())
211}
212
213pub async fn list_apps(
214 status: Option<String>,
215 page: u32,
216 page_size: u32,
217 output_format: &OutputFormat,
218) -> Result<()> {
219 let config = Config::load()?;
220 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
221
222 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
223
224 let mut query = vec![
225 ("page", page.to_string()),
226 ("page_size", page_size.to_string()),
227 ];
228
229 if let Some(s) = status {
230 query.push(("status", s));
231 }
232
233 let query_refs: Vec<(&str, String)> = query.iter().map(|(k, v)| (*k, v.clone())).collect();
234 let response: AppListResponse = client.get("/api/v1/apps", &query_refs).await?;
235
236 match output_format {
237 OutputFormat::Json | OutputFormat::Yaml => {
238 println!("{}", format_output(&response, *output_format)?);
239 }
240 OutputFormat::Table => {
241 if response.items.is_empty() {
242 println!("No applications found.");
243 return Ok(());
244 }
245
246 let mut table = create_table_with_headers(vec!["APP ID", "NAME", "STATUS", "USERS", "CREATED AT"]);
247
248 for item in &response.items {
249 let created_at = parse_and_format_date(&item.created_at);
250 table.add_row(vec![
251 &item.app_id,
252 &item.app_name,
253 &item.status,
254 &item.user_count.to_string(),
255 &created_at,
256 ]);
257 }
258
259 println!("{}", table);
260 println!(
261 "\nShowing {} of {} apps (page {}/{})",
262 response.items.len(),
263 response.total,
264 response.page,
265 (response.total + response.page_size - 1) / response.page_size
266 );
267 }
268 }
269
270 Ok(())
271}
272
273pub async fn show_app(app_id: String, output_format: &OutputFormat) -> Result<()> {
274 let config = Config::load()?;
275 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
276
277 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
278
279 let url = format!("/api/v1/apps/{}", app_id);
280 let response: AppDetail = client.get(&url, &[]).await?;
281
282 match output_format {
283 OutputFormat::Json | OutputFormat::Yaml => {
284 println!("{}", format_output(&response, *output_format)?);
285 }
286 OutputFormat::Table => {
287 println!("Application Details");
288 println!("==================");
289 println!();
290 println!(" App ID: {}", response.app_id);
291 println!(" Name: {}", response.app_name);
292 println!(" Status: {}", response.status);
293 println!();
294 println!("Database Configuration:");
295 println!(" Default Database: {}", response.default_database);
296 println!(" Allowed Databases: {}", response.allowed_databases.join(", "));
297 println!();
298 println!("Quotas:");
299 println!(" Max Users: {}", response.max_users);
300 println!(" Storage Quota (per user): {} MB", response.default_storage_quota_mb);
301 println!(" Max Connections (per user): {}", response.default_max_connections);
302 println!(" QPS Limit (per user): {}", response.default_qps);
303 println!();
304 println!("Usage:");
305 println!(" Current Users: {}", response.user_count);
306 println!(" Total Storage: {:.2} MB", response.total_storage_mb);
307 println!();
308 println!(" Created: {}", parse_and_format_date(&response.created_at));
309 println!(" Updated: {}", parse_and_format_date(&response.updated_at));
310 }
311 }
312
313 Ok(())
314}
315
316pub async fn update_app(
317 app_id: String,
318 name: Option<String>,
319 default_db: Option<String>,
320 allowed_dbs: Option<String>,
321 max_users: Option<u32>,
322 storage_quota: Option<u32>,
323 max_connections: Option<u32>,
324 qps_limit: Option<u32>,
325 output_format: &OutputFormat,
326) -> Result<()> {
327 let config = Config::load()?;
328 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
329
330 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
331
332 let allowed_databases = allowed_dbs.map(|dbs| {
333 dbs.split(',')
334 .map(|s| s.trim().to_string())
335 .filter(|s| !s.is_empty())
336 .collect()
337 });
338
339 let request = UpdateAppRequest {
340 app_name: name,
341 default_database: default_db,
342 allowed_databases,
343 max_users,
344 default_storage_quota_mb: storage_quota,
345 default_max_connections: max_connections,
346 default_qps: qps_limit,
347 status: None,
348 };
349
350 let url = format!("/api/v1/apps/{}", app_id);
351 let request_json = serde_json::to_value(&request)
352 .map_err(|e| CliError::SerializationError(format!("Failed to serialize request: {}", e)))?;
353 let response: UpdateAppResponse = client.patch(&url, &request_json).await?;
354
355 match output_format {
356 OutputFormat::Json | OutputFormat::Yaml => {
357 println!("{}", format_output(&response, *output_format)?);
358 }
359 OutputFormat::Table => {
360 print_success("App updated successfully");
361 println!();
362 println!(" App ID: {}", response.app_id);
363 println!(" Updated Fields: {}", response.updated_fields.join(", "));
364 println!(" Updated At: {}", parse_and_format_date(&response.updated_at));
365 }
366 }
367
368 Ok(())
369}
370
371pub async fn enable_app(app_id: String, output_format: &OutputFormat) -> Result<()> {
372 change_app_status(app_id, "active", output_format).await
373}
374
375pub async fn disable_app(app_id: String, output_format: &OutputFormat) -> Result<()> {
376 change_app_status(app_id, "disabled", output_format).await
377}
378
379async fn change_app_status(
380 app_id: String,
381 status: &str,
382 output_format: &OutputFormat,
383) -> Result<()> {
384 let config = Config::load()?;
385 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
386
387 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
388
389 let request = UpdateAppRequest {
390 app_name: None,
391 default_database: None,
392 allowed_databases: None,
393 max_users: None,
394 default_storage_quota_mb: None,
395 default_max_connections: None,
396 default_qps: None,
397 status: Some(status.to_string()),
398 };
399
400 let url = format!("/api/v1/apps/{}", app_id);
401 let request_json = serde_json::to_value(&request)
402 .map_err(|e| CliError::SerializationError(format!("Failed to serialize request: {}", e)))?;
403 let response: UpdateAppResponse = client.patch(&url, &request_json).await?;
404
405 match output_format {
406 OutputFormat::Json | OutputFormat::Yaml => {
407 println!("{}", format_output(&response, *output_format)?);
408 }
409 OutputFormat::Table => {
410 let action = if status == "active" { "enabled" } else { "disabled" };
411 print_success(&format!("App {} successfully", action));
412 }
413 }
414
415 Ok(())
416}
417
418pub async fn delete_app(app_id: String, force: bool, output_format: &OutputFormat) -> Result<()> {
419 let config = Config::load()?;
420 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
421
422 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
423
424 let url = format!("/api/v1/apps/{}", app_id);
426 let app: AppDetail = client.get(&url, &[]).await?;
427
428 if !force {
429 println!();
430 print_warning("⚠️ WARNING: This will permanently delete the app and all its users.");
431 println!(" App ID: {}", app.app_id);
432 println!(" App Name: {}", app.app_name);
433 println!(" Users: {}", app.user_count);
434 println!();
435
436 let confirmed = Confirm::new()
437 .with_prompt("Are you sure?")
438 .default(false)
439 .interact()
440 .map_err(|e| CliError::InvalidInput(format!("Failed to read input: {}", e)))?;
441
442 if !confirmed {
443 println!("Cancelled.");
444 return Ok(());
445 }
446 }
447
448 let response: DeleteAppResponse = client.delete(&url).await?;
449
450 match output_format {
451 OutputFormat::Json | OutputFormat::Yaml => {
452 println!("{}", format_output(&response, *output_format)?);
453 }
454 OutputFormat::Table => {
455 print_success("App deleted successfully");
456 println!();
457 println!(" Affected Users: {}", response.affected_users);
458 println!(" Workspace Cleanup: {}", response.workspace_cleanup);
459 }
460 }
461
462 Ok(())
463}
464
465pub async fn reset_token(app_id: String, output_format: &OutputFormat) -> Result<()> {
466 let config = Config::load()?;
467 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
468
469 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
470
471 let confirmed = Confirm::new()
473 .with_prompt("This will invalidate the old token. Continue?")
474 .default(false)
475 .interact()
476 .map_err(|e| CliError::InvalidInput(format!("Failed to read input: {}", e)))?;
477
478 if !confirmed {
479 println!("Cancelled.");
480 return Ok(());
481 }
482
483 let url = format!("/api/v1/apps/{}/rotate-token", app_id);
484 let response: ResetTokenResponse = client.post(&url, &serde_json::json!({})).await?;
485
486 match output_format {
487 OutputFormat::Json | OutputFormat::Yaml => {
488 println!("{}", format_output(&response, *output_format)?);
489 }
490 OutputFormat::Table => {
491 print_success("App token reset successfully");
492 println!();
493 println!(" App ID: {}", response.app_id);
494 println!(" New Token: {}", response.app_token);
495 println!();
496 print_warning("⚠️ Save the new token - it won't be shown again!");
497 }
498 }
499
500 Ok(())
501}
502
503pub async fn show_stats(app_id: String, output_format: &OutputFormat) -> Result<()> {
504 let config = Config::load()?;
505 let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
506
507 let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
508
509 let url = format!("/api/v1/apps/{}/stats", app_id);
510 let response: AppStats = client.get(&url, &[]).await?;
511
512 match output_format {
513 OutputFormat::Json | OutputFormat::Yaml => {
514 println!("{}", format_output(&response, *output_format)?);
515 }
516 OutputFormat::Table => {
517 println!("Application Statistics");
518 println!("=====================");
519 println!();
520 println!(" App ID: {}", response.app_id);
521 println!(" Total Users: {}", response.user_count);
522 println!(" Active Users: {}", response.active_user_count);
523 println!(" Total Storage: {:.2} MB", response.total_storage_mb);
524 println!(" Active Connections: {}", response.total_connections);
525 println!();
526
527 if !response.recent_users.is_empty() {
528 println!("Recent Users:");
529 let mut table = create_table_with_headers(vec!["USER UID", "LABEL", "LAST ACCESSED"]);
530
531 for user in &response.recent_users {
532 let label = user.label.as_deref().unwrap_or("-");
533 let last_accessed = parse_and_format_date(&user.last_accessed_at);
534 table.add_row(vec![&user.user_uid, label, &last_accessed]);
535 }
536
537 println!("{}", table);
538 }
539 }
540 }
541
542 Ok(())
543}
544
545fn parse_and_format_date(date_str: &str) -> String {
546 DateTime::parse_from_rfc3339(date_str)
547 .map(|dt| dt.with_timezone(&Utc).format("%Y-%m-%d %H:%M:%S UTC").to_string())
548 .unwrap_or_else(|_| date_str.to_string())
549}