1use std::io::{Write, stdout};
7
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use comfy_table::presets::UTF8_FULL;
11use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
12use ironflow_sdk::types::{
13 ApiKeyResponse, ApiKeyScope, ArtifactResponse, AuditLogEntry, CreateApiKeyResponse,
14 KeyVersionsResponse, RunDetailResponse, RunResponse, RunStatus, ScopeEntry, SecretResponse,
15 StatsHistoryResponse, StatsResponse, StepResponse, StepStatus, UserResponse,
16 WorkflowDetailResponse, WorkflowSummary,
17};
18use serde::Serialize;
19use serde_json::to_string_pretty;
20use uuid::Uuid;
21
22fn status_color(status: &RunStatus) -> Color {
24 match status {
25 RunStatus::Completed => Color::Green,
26 RunStatus::Failed => Color::Red,
27 RunStatus::Running => Color::Blue,
28 RunStatus::Pending => Color::Yellow,
29 RunStatus::Cancelled => Color::Grey,
30 RunStatus::AwaitingApproval => Color::Magenta,
31 RunStatus::Retrying => Color::Cyan,
32 RunStatus::Warning => Color::DarkYellow,
33 RunStatus::Sleeping => Color::DarkCyan,
34 }
35}
36
37fn step_status_color(status: &StepStatus) -> Color {
39 match status {
40 StepStatus::Completed => Color::Green,
41 StepStatus::Failed => Color::Red,
42 StepStatus::Running => Color::Blue,
43 StepStatus::Pending => Color::Yellow,
44 StepStatus::Skipped => Color::Grey,
45 StepStatus::AwaitingApproval => Color::Magenta,
46 StepStatus::Rejected => Color::Red,
47 }
48}
49
50fn format_datetime(dt: &DateTime<Utc>) -> String {
52 dt.format("%Y-%m-%d %H:%M:%S").to_string()
53}
54
55fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
57 dt.as_ref().map_or("-".to_string(), format_datetime)
58}
59
60const SLA_WARNING_RATIO: f64 = 0.1;
62
63fn format_remaining_secs(remaining: Option<i64>) -> String {
67 let Some(remaining) = remaining else {
68 return "-".to_string();
69 };
70 if remaining <= 0 {
71 return "expired".to_string();
72 }
73
74 if remaining < 60 {
75 return format!("{remaining}s");
76 }
77
78 let minutes = remaining / 60;
79 if minutes < 60 {
80 let rest = remaining % 60;
81 return if rest == 0 {
82 format!("{minutes}m")
83 } else {
84 format!("{minutes}m {rest}s")
85 };
86 }
87
88 let hours = minutes / 60;
89 let rest = minutes % 60;
90 if rest == 0 {
91 format!("{hours}h")
92 } else {
93 format!("{hours}h {rest}m")
94 }
95}
96
97fn remaining_color(remaining: Option<i64>, window_secs: Option<i64>) -> Option<Color> {
100 let remaining = remaining?;
101 if remaining <= 0 {
102 return Some(Color::Red);
103 }
104
105 let window = window_secs?;
106 if window > 0 && (remaining as f64) < (window as f64) * SLA_WARNING_RATIO {
107 return Some(Color::Yellow);
108 }
109
110 None
111}
112
113fn format_sla(step: &StepResponse) -> String {
119 format_remaining_secs(step.approval_seconds_remaining)
120}
121
122fn sla_color(step: &StepResponse) -> Option<Color> {
127 let window = match (step.approval_deadline_at, step.started_at) {
128 (Some(deadline), Some(started)) => Some((deadline - started).num_seconds()),
129 _ => None,
130 };
131 remaining_color(step.approval_seconds_remaining, window)
132}
133
134fn format_duration_ms(ms: i64) -> String {
136 if ms < 1000 {
137 return format!("{ms}ms");
138 }
139 let secs = ms / 1000;
140 if secs < 60 {
141 return format!("{secs}s");
142 }
143 let mins = secs / 60;
144 let remaining_secs = secs % 60;
145 if mins < 60 {
146 return format!("{mins}m {remaining_secs}s");
147 }
148 let hours = mins / 60;
149 let remaining_mins = mins % 60;
150 format!("{hours}h {remaining_mins}m")
151}
152
153fn base_table() -> Table {
155 let mut table = Table::new();
156 table
157 .load_preset(UTF8_FULL)
158 .set_content_arrangement(ContentArrangement::Dynamic);
159 table
160}
161
162pub fn render_output<W: Write, T: Serialize>(
168 writer: &mut W,
169 json_mode: bool,
170 value: &T,
171 table_fn: impl FnOnce() -> Table,
172) -> Result<()> {
173 if json_mode {
174 let json = to_string_pretty(value)?;
175 writeln!(writer, "{json}")?;
176 } else {
177 writeln!(writer, "{}", table_fn())?;
178 }
179 Ok(())
180}
181
182pub fn print_output<T: Serialize>(
188 json_mode: bool,
189 value: &T,
190 table_fn: impl FnOnce() -> Table,
191) -> Result<()> {
192 render_output(&mut stdout().lock(), json_mode, value, table_fn)
193}
194
195pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
204 let json = to_string_pretty(value)?;
205 writeln!(stdout().lock(), "{json}")?;
206 Ok(())
207}
208
209const COST_WARNING_RATIO: f64 = 0.8;
212
213fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
217 match max_cost_usd {
218 Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
219 None => format!("${cost_usd:.4}"),
220 }
221}
222
223fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
229 let cap = max_cost_usd?;
230
231 if cap <= 0.0 {
232 return (cost_usd > 0.0).then_some(Color::Red);
233 }
234
235 let ratio = cost_usd / cap;
236 if ratio >= 1.0 {
237 Some(Color::Red)
238 } else if ratio >= COST_WARNING_RATIO {
239 Some(Color::Yellow)
240 } else {
241 None
242 }
243}
244
245fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
247 let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
248 match cost_color(cost_usd, max_cost_usd) {
249 Some(color) => cell.fg(color),
250 None => cell,
251 }
252}
253
254pub fn runs_table(runs: &[RunResponse]) -> Table {
255 let mut table = base_table();
256 table.set_header(vec![
257 "ID",
258 "Workflow",
259 "Status",
260 "Triggered by",
261 "Duration",
262 "Cost",
263 "Created",
264 "Started",
265 ]);
266
267 for run in runs {
268 let status_cell = Cell::new(run.status)
269 .fg(status_color(&run.status))
270 .set_alignment(CellAlignment::Center);
271
272 table.add_row(vec![
273 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
274 Cell::new(&run.workflow_name),
275 status_cell,
276 Cell::new(&run.created_by.label),
277 Cell::new(format_duration_ms(run.duration_ms)),
278 cost_cell(run.cost_usd, run.max_cost_usd),
279 Cell::new(format_datetime(&run.created_at)),
280 Cell::new(format_optional_datetime(&run.started_at)),
281 ]);
282 }
283
284 table
285}
286
287pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
289 let run = &detail.run;
290 let mut table = base_table();
291 table.set_header(vec!["Field", "Value"]);
292
293 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
294
295 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
296 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
297 table.add_row(vec![Cell::new("Status"), status_cell]);
298 table.add_row(vec![
299 Cell::new("Trigger"),
300 Cell::new(format!("{:?}", run.trigger)),
301 ]);
302 table.add_row(vec![
303 Cell::new("Triggered by"),
304 Cell::new(&run.created_by.label),
305 ]);
306 table.add_row(vec![
307 Cell::new("Duration"),
308 Cell::new(format_duration_ms(run.duration_ms)),
309 ]);
310 table.add_row(vec![
311 Cell::new("Cost"),
312 cost_cell(run.cost_usd, run.max_cost_usd),
313 ]);
314 table.add_row(vec![
315 Cell::new("Created"),
316 Cell::new(format_datetime(&run.created_at)),
317 ]);
318 table.add_row(vec![
319 Cell::new("Started"),
320 Cell::new(format_optional_datetime(&run.started_at)),
321 ]);
322 table.add_row(vec![
323 Cell::new("Completed"),
324 Cell::new(format_optional_datetime(&run.completed_at)),
325 ]);
326 table.add_row(vec![
327 Cell::new("Retries"),
328 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
329 ]);
330
331 if let Some(ref error) = run.error {
332 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
333 }
334
335 if !detail.steps.is_empty() {
336 table.add_row(vec![
337 Cell::new("Steps"),
338 Cell::new(format!("{} step(s)", detail.steps.len())),
339 ]);
340 }
341
342 table
343}
344
345fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
349 if artifacts.is_empty() {
350 return "-".to_string();
351 }
352
353 let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
354 format!("{} ({})", artifacts.len(), format_bytes(total))
355}
356
357fn format_bytes(bytes: i64) -> String {
359 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
360
361 if bytes < 1024 {
362 return format!("{bytes} B");
363 }
364
365 let mut value = bytes as f64;
366 let mut unit = 0;
367 while value >= 1024.0 && unit < UNITS.len() - 1 {
368 value /= 1024.0;
369 unit += 1;
370 }
371
372 let decimals = if value < 10.0 { 1 } else { 0 };
373 format!("{value:.decimals$} {}", UNITS[unit])
374}
375
376pub fn steps_table(steps: &[StepResponse]) -> Table {
378 let mut table = base_table();
379 table.set_header(vec![
380 "ID",
381 "Name",
382 "Status",
383 "SLA",
384 "Attempt",
385 "Duration",
386 "Cost",
387 "Artifacts",
388 "Started",
389 "Completed",
390 ]);
391
392 for step in steps {
393 let color = step_status_color(&step.status);
394
395 let mut sla = Cell::new(format_sla(step)).set_alignment(CellAlignment::Center);
396 if let Some(sla_fg) = sla_color(step) {
397 sla = sla.fg(sla_fg);
398 }
399
400 table.add_row(vec![
401 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
402 Cell::new(&step.name),
403 Cell::new(step.status)
404 .fg(color)
405 .set_alignment(CellAlignment::Center),
406 sla,
407 Cell::new(step.attempt).set_alignment(CellAlignment::Center),
408 Cell::new(format_duration_ms(step.duration_ms)),
409 Cell::new(format!("${:.4}", step.cost_usd)),
410 Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
411 Cell::new(format_optional_datetime(&step.started_at)),
412 Cell::new(format_optional_datetime(&step.completed_at)),
413 ]);
414 }
415
416 table
417}
418
419pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
421 let mut table = base_table();
422 table.set_header(vec!["Name", "Category", "Version"]);
423
424 for wf in workflows {
425 table.add_row(vec![
426 Cell::new(&wf.name),
427 Cell::new(wf.category.as_deref().unwrap_or("-")),
428 Cell::new(wf.version.as_deref().unwrap_or("-")),
429 ]);
430 }
431
432 table
433}
434
435pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
437 let mut table = base_table();
438 table.set_header(vec!["Field", "Value"]);
439
440 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
441 table.add_row(vec![
442 Cell::new("Description"),
443 Cell::new(&detail.description),
444 ]);
445 table.add_row(vec![
446 Cell::new("Category"),
447 Cell::new(detail.category.as_deref().unwrap_or("-")),
448 ]);
449 table.add_row(vec![
450 Cell::new("Version"),
451 Cell::new(detail.version.as_deref().unwrap_or("-")),
452 ]);
453
454 if !detail.sub_workflows.is_empty() {
455 let names: Vec<&str> = detail
456 .sub_workflows
457 .iter()
458 .map(|s| s.name.as_str())
459 .collect();
460 table.add_row(vec![
461 Cell::new("Sub-workflows"),
462 Cell::new(names.join(", ")),
463 ]);
464 }
465
466 table
467}
468
469pub fn stats_table(stats: &StatsResponse) -> Table {
471 let mut table = base_table();
472 table.set_header(vec!["Metric", "Value"]);
473
474 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
475 table.add_row(vec![
476 Cell::new("Completed"),
477 Cell::new(stats.completed_runs).fg(Color::Green),
478 ]);
479 table.add_row(vec![
480 Cell::new("Failed"),
481 Cell::new(stats.failed_runs).fg(Color::Red),
482 ]);
483 table.add_row(vec![
484 Cell::new("Cancelled"),
485 Cell::new(stats.cancelled_runs).fg(Color::Grey),
486 ]);
487 table.add_row(vec![
488 Cell::new("Active"),
489 Cell::new(stats.active_runs).fg(Color::Blue),
490 ]);
491 table.add_row(vec![
492 Cell::new("Success rate"),
493 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
494 ]);
495 table.add_row(vec![
496 Cell::new("Total cost"),
497 Cell::new(format!("${:.4}", stats.total_cost_usd)),
498 ]);
499 table.add_row(vec![
500 Cell::new("Total duration"),
501 Cell::new(format_duration_ms(stats.total_duration_ms)),
502 ]);
503
504 table
505}
506
507pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
509 let mut table = base_table();
510 table.set_header(vec![
511 "Time",
512 "Completed",
513 "Failed",
514 "Cancelled",
515 "Avg (ms)",
516 "P95 (ms)",
517 "Cost",
518 ]);
519
520 for bucket in &history.buckets {
521 table.add_row(vec![
522 Cell::new(bucket.time),
523 Cell::new(bucket.completed).fg(Color::Green),
524 Cell::new(bucket.failed).fg(Color::Red),
525 Cell::new(bucket.cancelled).fg(Color::Grey),
526 Cell::new(bucket.avg_duration_ms),
527 Cell::new(bucket.p95_duration_ms),
528 Cell::new(format!("${:.4}", bucket.total_cost_usd)),
529 ]);
530 }
531
532 table
533}
534
535fn format_versions(versions: &[i32]) -> String {
537 if versions.is_empty() {
538 return "-".to_string();
539 }
540 versions
541 .iter()
542 .map(|v| v.to_string())
543 .collect::<Vec<_>>()
544 .join(", ")
545}
546
547#[derive(Debug, Serialize)]
561pub struct Deleted {
562 pub kind: &'static str,
564 pub id: String,
566 pub deleted: bool,
568}
569
570impl Deleted {
571 pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
573 Self {
574 kind,
575 id: id.into(),
576 deleted: true,
577 }
578 }
579}
580
581pub fn deleted_table(deleted: &Deleted) -> Table {
583 let mut table = base_table();
584 table.set_header(vec!["Deleted", "ID"]);
585 table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
586 table
587}
588
589pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
606 let deleted = Deleted::new(kind, id);
607 print_output(json_mode, &deleted, || deleted_table(&deleted))
608}
609
610pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
615 let mut table = base_table();
616 table.set_header(vec!["Key", "Created", "Updated"]);
617
618 for secret in secrets {
619 table.add_row(vec![
620 Cell::new(&secret.key),
621 Cell::new(format_datetime(&secret.created_at)),
622 Cell::new(format_datetime(&secret.updated_at)),
623 ]);
624 }
625
626 table
627}
628
629fn format_scopes(scopes: &[ApiKeyScope]) -> String {
631 scopes
632 .iter()
633 .map(ToString::to_string)
634 .collect::<Vec<_>>()
635 .join(", ")
636}
637
638pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
640 let mut table = base_table();
641 table.set_header(vec!["Property", "Versions"]);
642
643 table.add_row(vec![
644 Cell::new("Active"),
645 Cell::new(status.active).fg(Color::Green),
646 ]);
647 table.add_row(vec![
648 Cell::new("Configured"),
649 Cell::new(format_versions(&status.configured)),
650 ]);
651 table.add_row(vec![
652 Cell::new("In use"),
653 Cell::new(format_versions(&status.in_use)),
654 ]);
655 table.add_row(vec![
656 Cell::new("Missing"),
657 Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
658 Color::Grey
659 } else {
660 Color::Red
661 }),
662 ]);
663 table.add_row(vec![
664 Cell::new("Retirable"),
665 Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
666 Color::Grey
667 } else {
668 Color::Yellow
669 }),
670 ]);
671
672 table
673}
674
675pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
679 let mut table = base_table();
680 table.set_header(vec![
681 "ID",
682 "Name",
683 "Prefix",
684 "Scopes",
685 "Active",
686 "Rate limit",
687 "Last used",
688 "Expires",
689 "Created",
690 ]);
691
692 for key in keys {
693 let active = Cell::new(if key.is_active { "yes" } else { "no" })
694 .fg(if key.is_active {
695 Color::Green
696 } else {
697 Color::Grey
698 })
699 .set_alignment(CellAlignment::Center);
700
701 let rate_limit = key
702 .rate_limit_override
703 .map(|v| v.to_string())
704 .unwrap_or_else(|| "-".to_string());
705
706 table.add_row(vec![
707 Cell::new(key.id),
708 Cell::new(&key.name),
709 Cell::new(&key.key_prefix),
710 Cell::new(format_scopes(&key.scopes)),
711 active,
712 Cell::new(rate_limit),
713 Cell::new(format_optional_datetime(&key.last_used_at)),
714 Cell::new(format_optional_datetime(&key.expires_at)),
715 Cell::new(format_datetime(&key.created_at)),
716 ]);
717 }
718
719 table
720}
721
722pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
728 let mut table = base_table();
729 table.set_header(vec!["Field", "Value"]);
730
731 table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
732 table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
733 table.add_row(vec![
734 Cell::new("Key"),
735 Cell::new(&key.key).fg(Color::Yellow),
736 ]);
737 table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
738 table.add_row(vec![
739 Cell::new("Scopes"),
740 Cell::new(format_scopes(&key.scopes)),
741 ]);
742 if let Some(override_val) = key.rate_limit_override {
743 table.add_row(vec![
744 Cell::new("Rate limit"),
745 Cell::new(format!("{override_val} req/min")),
746 ]);
747 }
748 table.add_row(vec![
749 Cell::new("Expires"),
750 Cell::new(format_optional_datetime(&key.expires_at)),
751 ]);
752 table.add_row(vec![
753 Cell::new("Created"),
754 Cell::new(format_datetime(&key.created_at)),
755 ]);
756
757 table
758}
759
760pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
762 let mut table = base_table();
763 table.set_header(vec!["Value", "Label", "Description"]);
764
765 for scope in scopes {
766 table.add_row(vec![
767 Cell::new(&scope.value),
768 Cell::new(&scope.label),
769 Cell::new(&scope.description),
770 ]);
771 }
772
773 table
774}
775
776pub fn users_table(users: &[UserResponse]) -> Table {
778 let mut table = base_table();
779 table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
780
781 for user in users {
782 let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
783 .fg(if user.is_admin {
784 Color::Magenta
785 } else {
786 Color::Grey
787 })
788 .set_alignment(CellAlignment::Center);
789
790 table.add_row(vec![
791 Cell::new(user.id),
792 Cell::new(&user.username),
793 Cell::new(&user.email),
794 admin,
795 Cell::new(format_datetime(&user.created_at)),
796 ]);
797 }
798
799 table
800}
801
802pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
804 let (ra, rb) = (&a.run, &b.run);
805 let mut table = base_table();
806 table.set_header(vec![
807 "Field",
808 &format!("Run {}", short_id(ra.id)),
809 &format!("Run {}", short_id(rb.id)),
810 ]);
811
812 let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
813 let hl = va != vb;
814 vec![
815 Cell::new(f),
816 if hl {
817 Cell::new(&va).fg(Color::Yellow)
818 } else {
819 Cell::new(&va)
820 },
821 if hl {
822 Cell::new(&vb).fg(Color::Yellow)
823 } else {
824 Cell::new(&vb)
825 },
826 ]
827 };
828
829 table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
830 table.add_row(row(
831 "Duration",
832 format_duration_ms(ra.duration_ms),
833 format_duration_ms(rb.duration_ms),
834 ));
835 table.add_row(row(
836 "Cost",
837 format_cost(ra.cost_usd, ra.max_cost_usd),
838 format_cost(rb.cost_usd, rb.max_cost_usd),
839 ));
840 table.add_row(row(
841 "Started",
842 format_optional_datetime(&ra.started_at),
843 format_optional_datetime(&rb.started_at),
844 ));
845 table.add_row(row(
846 "Completed",
847 format_optional_datetime(&ra.completed_at),
848 format_optional_datetime(&rb.completed_at),
849 ));
850 table.add_row(row(
851 "Error",
852 ra.error.clone().unwrap_or("-".into()),
853 rb.error.clone().unwrap_or("-".into()),
854 ));
855 if a.payload != b.payload {
856 table.add_row(row(
857 "Payload",
858 serde_json::to_string(&a.payload).unwrap_or_default(),
859 serde_json::to_string(&b.payload).unwrap_or_default(),
860 ));
861 }
862 for i in 0..a.steps.len().max(b.steps.len()) {
863 let (sa, sb) = (a.steps.get(i), b.steps.get(i));
864 let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
865 table.add_row(row(
866 &format!("{name} status"),
867 sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
868 sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
869 ));
870 table.add_row(row(
871 &format!("{name} duration"),
872 sa.map(|s| format_duration_ms(s.duration_ms))
873 .unwrap_or("-".into()),
874 sb.map(|s| format_duration_ms(s.duration_ms))
875 .unwrap_or("-".into()),
876 ));
877 table.add_row(row(
878 &format!("{name} cost"),
879 sa.map(|s| format!("${:.4}", s.cost_usd))
880 .unwrap_or("-".into()),
881 sb.map(|s| format!("${:.4}", s.cost_usd))
882 .unwrap_or("-".into()),
883 ));
884 }
885 table
886}
887
888fn short_id(id: Uuid) -> String {
890 id.to_string()
891 .split('-')
892 .next()
893 .unwrap_or_default()
894 .to_string()
895}
896
897fn format_optional_id(id: &Option<Uuid>) -> String {
899 id.map_or_else(|| "-".to_string(), short_id)
900}
901
902pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
907 let mut table = base_table();
908 table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
909
910 for entry in entries {
911 table.add_row(vec![
912 Cell::new(short_id(entry.id)),
913 Cell::new(entry.event_type.to_string()),
914 Cell::new(format_optional_id(&entry.run_id)),
915 Cell::new(format_optional_id(&entry.step_id)),
916 Cell::new(format_optional_id(&entry.user_id)),
917 Cell::new(format_datetime(&entry.created_at)),
918 ]);
919 }
920
921 table
922}
923
924#[cfg(test)]
925mod tests {
926 use std::collections::HashMap;
927 use std::slice;
928
929 use ironflow_sdk::types::{ApiKeyScope, CreatedBy, CreatedByKind, EventKind, TriggerKind};
930 use serde_json::{Map, Value};
931
932 use super::*;
933
934 fn run_fixture(created_by: CreatedBy) -> RunResponse {
936 let now = Utc::now();
937 RunResponse {
938 id: Uuid::now_v7(),
939 workflow_name: "deploy".to_string(),
940 status: RunStatus::Completed,
941 trigger: TriggerKind::Api,
942 error: None,
943 retry_count: 0,
944 max_retries: 0,
945 cost_usd: 0.0,
946 duration_ms: 0,
947 created_at: now,
948 updated_at: now,
949 started_at: None,
950 completed_at: None,
951 handler_version: None,
952 labels: HashMap::new(),
953 scheduled_at: None,
954 created_by,
955 idempotency_key: None,
956 max_cost_usd: None,
957 }
958 }
959
960 #[test]
961 fn format_cost_without_cap_shows_amount_only() {
962 assert_eq!(format_cost(0.1234, None), "$0.1234");
963 }
964
965 #[test]
966 fn format_cost_with_cap_shows_both_amounts() {
967 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
968 }
969
970 #[test]
971 fn cost_color_is_absent_without_a_cap() {
972 assert_eq!(cost_color(999.0, None), None);
973 }
974
975 #[test]
976 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
977 assert_eq!(cost_color(1.0, Some(2.0)), None); assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); assert_eq!(cost_color(1.99, Some(2.0)), Some(Color::Yellow));
980 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
983
984 #[test]
985 fn cost_color_handles_a_zero_cap() {
986 assert_eq!(cost_color(0.0, Some(0.0)), None);
987 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
988 }
989
990 fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
991 ArtifactResponse {
992 id: Uuid::now_v7(),
993 step_id: Uuid::now_v7(),
994 name: name.to_string(),
995 content_type: "text/plain".to_string(),
996 size_bytes,
997 sha256: "0".repeat(64),
998 created_at: Utc::now(),
999 }
1000 }
1001
1002 #[test]
1003 fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
1004 assert_eq!(format_bytes(0), "0 B");
1005 assert_eq!(format_bytes(1023), "1023 B");
1006 }
1007
1008 #[test]
1009 fn format_bytes_switches_units_at_each_boundary() {
1010 assert_eq!(format_bytes(1024), "1.0 KB");
1011 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1012 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1013 }
1014
1015 #[test]
1016 fn format_bytes_drops_the_decimal_past_ten() {
1017 assert_eq!(format_bytes(145_408), "142 KB");
1018 }
1019
1020 #[test]
1021 fn format_artifacts_shows_a_dash_when_there_are_none() {
1022 assert_eq!(format_artifacts(&[]), "-");
1023 }
1024
1025 #[test]
1026 fn format_artifacts_shows_the_count_and_total_size() {
1027 let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
1028 assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
1029 }
1030
1031 #[test]
1032 fn format_duration_ms_millis() {
1033 assert_eq!(format_duration_ms(500), "500ms");
1034 assert_eq!(format_duration_ms(0), "0ms");
1035 }
1036
1037 #[test]
1038 fn format_duration_ms_seconds() {
1039 assert_eq!(format_duration_ms(5000), "5s");
1040 assert_eq!(format_duration_ms(59000), "59s");
1041 }
1042
1043 #[test]
1044 fn format_duration_ms_minutes() {
1045 assert_eq!(format_duration_ms(60000), "1m 0s");
1046 assert_eq!(format_duration_ms(125000), "2m 5s");
1047 }
1048
1049 #[test]
1050 fn format_duration_ms_hours() {
1051 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
1052 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
1053 }
1054
1055 #[test]
1056 fn format_sla_without_a_deadline_is_a_dash() {
1057 assert_eq!(format_remaining_secs(None), "-");
1058 }
1059
1060 #[test]
1061 fn format_sla_reports_an_elapsed_deadline_as_expired() {
1062 assert_eq!(format_remaining_secs(Some(0)), "expired");
1063 assert_eq!(format_remaining_secs(Some(-30)), "expired");
1064 }
1065
1066 #[test]
1067 fn format_sla_uses_coarse_units() {
1068 assert_eq!(format_remaining_secs(Some(45)), "45s");
1069 assert_eq!(format_remaining_secs(Some(59)), "59s");
1070 assert_eq!(format_remaining_secs(Some(60)), "1m");
1071 assert_eq!(format_remaining_secs(Some(750)), "12m 30s");
1072 assert_eq!(format_remaining_secs(Some(3599)), "59m 59s");
1073 assert_eq!(format_remaining_secs(Some(3600)), "1h");
1074 assert_eq!(format_remaining_secs(Some(4320)), "1h 12m");
1075 }
1076
1077 #[test]
1078 fn sla_has_no_colour_without_a_deadline() {
1079 assert_eq!(remaining_color(None, None), None);
1080 assert_eq!(remaining_color(None, Some(3600)), None);
1081 }
1082
1083 #[test]
1084 fn sla_turns_red_once_expired() {
1085 assert_eq!(remaining_color(Some(0), Some(3600)), Some(Color::Red));
1086 assert_eq!(remaining_color(Some(-1), None), Some(Color::Red));
1087 }
1088
1089 #[test]
1090 fn sla_turns_yellow_in_the_last_tenth_of_the_window() {
1091 assert_eq!(remaining_color(Some(359), Some(3600)), Some(Color::Yellow));
1092 assert_eq!(remaining_color(Some(360), Some(3600)), None);
1093 assert_eq!(remaining_color(Some(3000), Some(3600)), None);
1094 }
1095
1096 #[test]
1097 fn sla_has_no_colour_without_a_measurable_window() {
1098 assert_eq!(remaining_color(Some(120), None), None);
1099 assert_eq!(remaining_color(Some(120), Some(0)), None);
1100 }
1101
1102 #[test]
1103 fn format_optional_datetime_none() {
1104 assert_eq!(format_optional_datetime(&None), "-");
1105 }
1106
1107 #[test]
1108 fn format_optional_datetime_some() {
1109 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
1110 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
1111 }
1112
1113 #[test]
1114 fn status_colors_are_distinct() {
1115 let statuses = [
1116 RunStatus::Completed,
1117 RunStatus::Failed,
1118 RunStatus::Running,
1119 RunStatus::Pending,
1120 RunStatus::Cancelled,
1121 RunStatus::AwaitingApproval,
1122 RunStatus::Retrying,
1123 ];
1124
1125 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
1126 for (i, c1) in colors.iter().enumerate() {
1127 for (j, c2) in colors.iter().enumerate() {
1128 if i != j {
1129 assert_ne!(c1, c2, "status colors must be distinct");
1130 }
1131 }
1132 }
1133 }
1134
1135 #[test]
1136 fn empty_runs_table_has_header() {
1137 let table = runs_table(&[]);
1138 let output = table.to_string();
1139 assert!(output.contains("ID"));
1140 assert!(output.contains("Workflow"));
1141 assert!(output.contains("Status"));
1142 assert!(output.contains("Triggered by"));
1143 }
1144
1145 #[test]
1146 fn runs_table_renders_the_author_label() {
1147 let run = run_fixture(CreatedBy {
1148 kind: CreatedByKind::ApiKey,
1149 id: Some(Uuid::now_v7()),
1150 label: "ci-deploy (alice)".to_string(),
1151 });
1152
1153 let output = runs_table(slice::from_ref(&run)).to_string();
1154 assert!(
1155 output.contains("ci-deploy (alice)"),
1156 "author missing from:\n{output}"
1157 );
1158 }
1159
1160 #[test]
1161 fn run_detail_table_renders_the_author_label() {
1162 let detail = RunDetailResponse {
1163 run: run_fixture(CreatedBy {
1164 kind: CreatedByKind::System,
1165 id: None,
1166 label: "/hooks/github".to_string(),
1167 }),
1168 steps: Vec::new(),
1169 payload: Value::Object(Map::new()),
1170 };
1171
1172 let output = run_detail_table(&detail).to_string();
1173 assert!(output.contains("Triggered by"));
1174 assert!(
1175 output.contains("/hooks/github"),
1176 "author missing from:\n{output}"
1177 );
1178 }
1179
1180 #[test]
1181 fn empty_workflows_table_has_header() {
1182 let table = workflows_table(&[]);
1183 let output = table.to_string();
1184 assert!(output.contains("Name"));
1185 assert!(output.contains("Category"));
1186 }
1187
1188 fn secret_fixture(key: &str) -> SecretResponse {
1191 let now = Utc::now();
1192 SecretResponse {
1193 id: Uuid::now_v7(),
1194 key: key.to_string(),
1195 created_at: now,
1196 updated_at: now,
1197 }
1198 }
1199
1200 #[test]
1201 fn empty_secrets_table_has_header() {
1202 let output = secrets_table(&[]).to_string();
1203 assert!(output.contains("Key"));
1204 assert!(output.contains("Created"));
1205 assert!(output.contains("Updated"));
1206 }
1207
1208 #[test]
1209 fn secrets_table_renders_the_key() {
1210 let secret = secret_fixture("workflows/inbox/gmail_token");
1211 let output = secrets_table(slice::from_ref(&secret)).to_string();
1212 assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1213 }
1214
1215 #[test]
1218 fn a_secret_response_carries_no_value_at_all() {
1219 let secret = secret_fixture("db/password");
1220 let json = serde_json::to_string(&secret).unwrap();
1221 assert!(!json.contains("value"), "{json}");
1222 }
1223
1224 fn api_key_fixture() -> ApiKeyResponse {
1227 ApiKeyResponse {
1228 id: Uuid::now_v7(),
1229 name: "ci-deploy".to_string(),
1230 key_prefix: "ifk_abcd".to_string(),
1231 scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1232 is_active: true,
1233 created_at: Utc::now(),
1234 expires_at: None,
1235 last_used_at: None,
1236 rate_limit_override: None,
1237 }
1238 }
1239
1240 #[test]
1241 fn empty_api_keys_table_has_header() {
1242 let output = api_keys_table(&[]).to_string();
1243 for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1244 assert!(output.contains(header), "missing {header} in {output}");
1245 }
1246 }
1247
1248 #[test]
1249 fn api_keys_table_joins_the_scopes() {
1250 let key = api_key_fixture();
1251 let output = api_keys_table(slice::from_ref(&key)).to_string();
1252 assert!(output.contains("runs_read, runs_write"), "{output}");
1253 assert!(output.contains("ifk_abcd"), "{output}");
1254 }
1255
1256 #[test]
1257 fn created_api_key_table_shows_the_raw_key() {
1258 let created = CreateApiKeyResponse {
1259 id: Uuid::now_v7(),
1260 name: "ci-deploy".to_string(),
1261 key: "ifk_full_raw_key".to_string(),
1262 key_prefix: "ifk_full".to_string(),
1263 scopes: vec![ApiKeyScope::Admin],
1264 created_at: Utc::now(),
1265 expires_at: None,
1266 rate_limit_override: None,
1267 };
1268
1269 let output = created_api_key_table(&created).to_string();
1270 assert!(output.contains("ifk_full_raw_key"), "{output}");
1271 }
1272
1273 #[test]
1274 fn empty_scopes_table_has_header() {
1275 let output = scopes_table(&[]).to_string();
1276 assert!(output.contains("Value"));
1277 assert!(output.contains("Description"));
1278 }
1279
1280 fn user_fixture(is_admin: bool) -> UserResponse {
1283 let now = Utc::now();
1284 UserResponse {
1285 id: Uuid::now_v7(),
1286 username: "alice".to_string(),
1287 email: "alice@example.com".to_string(),
1288 is_admin,
1289 created_at: now,
1290 updated_at: now,
1291 }
1292 }
1293
1294 #[test]
1295 fn empty_users_table_has_header() {
1296 let output = users_table(&[]).to_string();
1297 for header in ["ID", "Username", "Email", "Admin", "Created"] {
1298 assert!(output.contains(header), "missing {header} in {output}");
1299 }
1300 }
1301
1302 #[test]
1303 fn users_table_spells_out_the_role() {
1304 let admin = user_fixture(true);
1305 assert!(
1306 users_table(slice::from_ref(&admin))
1307 .to_string()
1308 .contains("yes")
1309 );
1310
1311 let member = user_fixture(false);
1312 assert!(
1313 users_table(slice::from_ref(&member))
1314 .to_string()
1315 .contains("no")
1316 );
1317 }
1318
1319 #[test]
1322 fn empty_audit_logs_table_has_header() {
1323 let output = audit_logs_table(&[]).to_string();
1324 for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1325 assert!(output.contains(header), "missing {header} in {output}");
1326 }
1327 }
1328
1329 #[test]
1330 fn audit_logs_table_omits_the_payload() {
1331 let entry = AuditLogEntry {
1332 id: Uuid::now_v7(),
1333 event_type: EventKind::RunCreated,
1334 payload: Value::Object(Map::new()),
1335 run_id: Some(Uuid::now_v7()),
1336 step_id: None,
1337 user_id: None,
1338 created_at: Utc::now(),
1339 };
1340
1341 let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1342 assert!(output.contains("run_created"), "{output}");
1343 assert!(output.contains('-'), "{output}");
1345 }
1346
1347 #[test]
1348 fn format_optional_id_shortens_and_falls_back() {
1349 assert_eq!(format_optional_id(&None), "-");
1350 let id = Uuid::now_v7();
1351 let short = format_optional_id(&Some(id));
1352 assert_eq!(short, id.to_string().split('-').next().unwrap());
1353 }
1354
1355 #[test]
1358 fn deleted_table_reports_the_kind_and_id() {
1359 let deleted = Deleted::new("secret", "db/password");
1360 let output = deleted_table(&deleted).to_string();
1361 assert!(output.contains("secret"), "{output}");
1362 assert!(output.contains("db/password"), "{output}");
1363
1364 let json = serde_json::to_string(&deleted).unwrap();
1365 assert!(json.contains(r#""deleted":true"#), "{json}");
1366 }
1367}