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::client::ApiResponse;
13use ironflow_sdk::types::{
14 AccountState, AccountWindowResponse, AccountWindowStatus, ApiKeyResponse, ApiKeyScope,
15 ArtifactResponse, AuditLogEntry, ConcurrencyLimit, CreateApiKeyResponse, ExecutionPlanResponse,
16 KeyVersionsResponse, PlannedStepResponse, ProviderAccountResponse, RunDetailResponse,
17 RunResponse, RunStatus, ScopeEntry, SecretResponse, StatsHistoryResponse, StatsResponse,
18 StepResponse, StepStatus, UserGroupsResponse, UserResponse, WorkerRouting,
19 WorkflowDetailResponse, WorkflowSummary,
20};
21use serde::Serialize;
22use serde_json::to_string_pretty;
23use uuid::Uuid;
24
25mod cancel;
26mod pause;
27
28pub use cancel::cancelled_table;
29pub use pause::{paused_table, resumed_table, workflow_pause_table};
30
31fn status_color(status: &RunStatus) -> Color {
33 match status {
34 RunStatus::Completed => Color::Green,
35 RunStatus::Failed => Color::Red,
36 RunStatus::Running => Color::Blue,
37 RunStatus::Pending => Color::Yellow,
38 RunStatus::Cancelled => Color::Grey,
39 RunStatus::AwaitingApproval => Color::Magenta,
40 RunStatus::Retrying => Color::Cyan,
41 RunStatus::Warning => Color::DarkYellow,
42 RunStatus::Sleeping => Color::DarkCyan,
43 RunStatus::Paused => Color::DarkMagenta,
44 }
45}
46
47fn step_status_color(status: &StepStatus) -> Color {
49 match status {
50 StepStatus::Completed => Color::Green,
51 StepStatus::Failed => Color::Red,
52 StepStatus::Running => Color::Blue,
53 StepStatus::Pending => Color::Yellow,
54 StepStatus::Skipped => Color::Grey,
55 StepStatus::AwaitingApproval => Color::Magenta,
56 StepStatus::Rejected => Color::Red,
57 }
58}
59
60fn format_datetime(dt: &DateTime<Utc>) -> String {
62 dt.format("%Y-%m-%d %H:%M:%S").to_string()
63}
64
65fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
67 dt.as_ref().map_or("-".to_string(), format_datetime)
68}
69
70const SLA_WARNING_RATIO: f64 = 0.1;
72
73fn format_remaining_secs(remaining: Option<i64>) -> String {
77 let Some(remaining) = remaining else {
78 return "-".to_string();
79 };
80 if remaining <= 0 {
81 return "expired".to_string();
82 }
83
84 if remaining < 60 {
85 return format!("{remaining}s");
86 }
87
88 let minutes = remaining / 60;
89 if minutes < 60 {
90 let rest = remaining % 60;
91 return if rest == 0 {
92 format!("{minutes}m")
93 } else {
94 format!("{minutes}m {rest}s")
95 };
96 }
97
98 let hours = minutes / 60;
99 let rest = minutes % 60;
100 if rest == 0 {
101 format!("{hours}h")
102 } else {
103 format!("{hours}h {rest}m")
104 }
105}
106
107fn remaining_color(remaining: Option<i64>, window_secs: Option<i64>) -> Option<Color> {
110 let remaining = remaining?;
111 if remaining <= 0 {
112 return Some(Color::Red);
113 }
114
115 let window = window_secs?;
116 if window > 0 && (remaining as f64) < (window as f64) * SLA_WARNING_RATIO {
117 return Some(Color::Yellow);
118 }
119
120 None
121}
122
123fn format_sla(step: &StepResponse) -> String {
129 format_remaining_secs(step.approval_seconds_remaining)
130}
131
132fn sla_color(step: &StepResponse) -> Option<Color> {
137 let window = match (step.approval_deadline_at, step.started_at) {
138 (Some(deadline), Some(started)) => Some((deadline - started).num_seconds()),
139 _ => None,
140 };
141 remaining_color(step.approval_seconds_remaining, window)
142}
143
144fn format_duration_ms(ms: i64) -> String {
146 if ms < 1000 {
147 return format!("{ms}ms");
148 }
149 let secs = ms / 1000;
150 if secs < 60 {
151 return format!("{secs}s");
152 }
153 let mins = secs / 60;
154 let remaining_secs = secs % 60;
155 if mins < 60 {
156 return format!("{mins}m {remaining_secs}s");
157 }
158 let hours = mins / 60;
159 let remaining_mins = mins % 60;
160 format!("{hours}h {remaining_mins}m")
161}
162
163fn base_table() -> Table {
165 let mut table = Table::new();
166 table
167 .load_preset(UTF8_FULL)
168 .set_content_arrangement(ContentArrangement::Dynamic);
169 table
170}
171
172pub fn render_output<W: Write, T: Serialize>(
178 writer: &mut W,
179 json_mode: bool,
180 value: &T,
181 table_fn: impl FnOnce() -> Table,
182) -> Result<()> {
183 if json_mode {
184 let json = to_string_pretty(value)?;
185 writeln!(writer, "{json}")?;
186 } else {
187 writeln!(writer, "{}", table_fn())?;
188 }
189 Ok(())
190}
191
192pub fn print_output<T: Serialize>(
198 json_mode: bool,
199 value: &T,
200 table_fn: impl FnOnce() -> Table,
201) -> Result<()> {
202 render_output(&mut stdout().lock(), json_mode, value, table_fn)
203}
204
205pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
214 let json = to_string_pretty(value)?;
215 writeln!(stdout().lock(), "{json}")?;
216 Ok(())
217}
218
219const COST_WARNING_RATIO: f64 = 0.8;
222
223fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
227 match max_cost_usd {
228 Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
229 None => format!("${cost_usd:.4}"),
230 }
231}
232
233fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
239 let cap = max_cost_usd?;
240
241 if cap <= 0.0 {
242 return (cost_usd > 0.0).then_some(Color::Red);
243 }
244
245 let ratio = cost_usd / cap;
246 if ratio >= 1.0 {
247 Some(Color::Red)
248 } else if ratio >= COST_WARNING_RATIO {
249 Some(Color::Yellow)
250 } else {
251 None
252 }
253}
254
255fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
257 let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
258 match cost_color(cost_usd, max_cost_usd) {
259 Some(color) => cell.fg(color),
260 None => cell,
261 }
262}
263
264pub fn runs_table(runs: &[RunResponse]) -> Table {
265 let mut table = base_table();
266 table.set_header(vec![
267 "ID",
268 "Workflow",
269 "Status",
270 "Priority",
271 "Triggered by",
272 "Duration",
273 "Cost",
274 "Created",
275 "Started",
276 ]);
277
278 for run in runs {
279 let status_cell = Cell::new(run.status)
280 .fg(status_color(&run.status))
281 .set_alignment(CellAlignment::Center);
282
283 table.add_row(vec![
284 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
285 Cell::new(&run.workflow_name),
286 status_cell,
287 Cell::new(format_priority(run.priority)).set_alignment(CellAlignment::Right),
288 Cell::new(&run.created_by.label),
289 Cell::new(format_duration_ms(run.duration_ms)),
290 cost_cell(run.cost_usd, run.max_cost_usd),
291 Cell::new(format_datetime(&run.created_at)),
292 Cell::new(format_optional_datetime(&run.started_at)),
293 ]);
294 }
295
296 table
297}
298
299pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
301 let run = &detail.run;
302 let mut table = base_table();
303 table.set_header(vec!["Field", "Value"]);
304
305 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
306
307 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
308 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
309 table.add_row(vec![Cell::new("Status"), status_cell]);
310 table.add_row(vec![
311 Cell::new("Priority"),
312 Cell::new(format_priority(run.priority)),
313 ]);
314 table.add_row(vec![
315 Cell::new("Trigger"),
316 Cell::new(format!("{:?}", run.trigger)),
317 ]);
318 table.add_row(vec![
319 Cell::new("Triggered by"),
320 Cell::new(&run.created_by.label),
321 ]);
322 table.add_row(vec![
323 Cell::new("Duration"),
324 Cell::new(format_duration_ms(run.duration_ms)),
325 ]);
326 table.add_row(vec![
327 Cell::new("Cost"),
328 cost_cell(run.cost_usd, run.max_cost_usd),
329 ]);
330 table.add_row(vec![
331 Cell::new("Created"),
332 Cell::new(format_datetime(&run.created_at)),
333 ]);
334 table.add_row(vec![
335 Cell::new("Started"),
336 Cell::new(format_optional_datetime(&run.started_at)),
337 ]);
338 table.add_row(vec![
339 Cell::new("Completed"),
340 Cell::new(format_optional_datetime(&run.completed_at)),
341 ]);
342 table.add_row(vec![
343 Cell::new("Retries"),
344 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
345 ]);
346 table.add_row(vec![
347 Cell::new("Lease recoveries"),
348 Cell::new(run.lease_recoveries.to_string()),
349 ]);
350
351 if !run.concurrency_limits.is_empty() {
352 table.add_row(vec![
353 Cell::new("Concurrency groups"),
354 Cell::new(format_concurrency_limits(&run.concurrency_limits)),
355 ]);
356 }
357
358 if !run.worker_tags.is_empty() {
359 table.add_row(vec![
360 Cell::new("Worker tags"),
361 Cell::new(run.worker_tags.join(", ")),
362 ]);
363 }
364
365 if let Some(warning) = detail.worker_routing.as_ref().and_then(routing_warning) {
366 table.add_row(vec![
367 Cell::new("Workers"),
368 Cell::new(warning).fg(Color::Yellow),
369 ]);
370 }
371
372 if let Some(ref kind) = run.capacity_wait_kind {
373 let resumes = format_optional_datetime(&run.scheduled_at);
374 let reason = format!("{kind}, resumes at {resumes}");
375 table.add_row(vec![
376 Cell::new("Waiting for capacity"),
377 Cell::new(reason).fg(Color::DarkCyan),
378 ]);
379 }
380
381 if let Some(ref error) = run.error {
382 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
383 }
384
385 if let Some(ref output) = run.output {
386 table.add_row(vec![Cell::new("Output"), Cell::new(output)]);
387 }
388
389 if !detail.steps.is_empty() {
390 table.add_row(vec![
391 Cell::new("Steps"),
392 Cell::new(format!("{} step(s)", detail.steps.len())),
393 ]);
394 }
395
396 table
397}
398
399fn format_priority(priority: Option<i32>) -> String {
401 priority.map_or_else(|| "-".to_string(), |p| p.to_string())
402}
403
404fn routing_warning(routing: &WorkerRouting) -> Option<&'static str> {
407 if routing.seen_workers == 0 {
408 Some("No worker seen recently")
409 } else if routing.eligible_workers == 0 {
410 Some("No eligible worker seen: none registers this workflow with every required tag")
411 } else {
412 None
413 }
414}
415
416fn format_concurrency_limits(limits: &[ConcurrencyLimit]) -> String {
418 limits
419 .iter()
420 .map(|l| format!("{} ({})", l.group, l.limit))
421 .collect::<Vec<_>>()
422 .join(", ")
423}
424
425fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
429 if artifacts.is_empty() {
430 return "-".to_string();
431 }
432
433 let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
434 format!("{} ({})", artifacts.len(), format_bytes(total))
435}
436
437fn format_bytes(bytes: i64) -> String {
439 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
440
441 if bytes < 1024 {
442 return format!("{bytes} B");
443 }
444
445 let mut value = bytes as f64;
446 let mut unit = 0;
447 while value >= 1024.0 && unit < UNITS.len() - 1 {
448 value /= 1024.0;
449 unit += 1;
450 }
451
452 let decimals = if value < 10.0 { 1 } else { 0 };
453 format!("{value:.decimals$} {}", UNITS[unit])
454}
455
456pub fn steps_table(steps: &[StepResponse]) -> Table {
458 let mut table = base_table();
459 table.set_header(vec![
460 "ID",
461 "Name",
462 "Status",
463 "SLA",
464 "Attempt",
465 "Duration",
466 "Cost",
467 "Artifacts",
468 "Started",
469 "Completed",
470 ]);
471
472 for step in steps {
473 let color = step_status_color(&step.status);
474
475 let mut sla = Cell::new(format_sla(step)).set_alignment(CellAlignment::Center);
476 if let Some(sla_fg) = sla_color(step) {
477 sla = sla.fg(sla_fg);
478 }
479
480 table.add_row(vec![
481 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
482 Cell::new(&step.name),
483 Cell::new(step.status)
484 .fg(color)
485 .set_alignment(CellAlignment::Center),
486 sla,
487 Cell::new(step.attempt).set_alignment(CellAlignment::Center),
488 Cell::new(format_duration_ms(step.duration_ms)),
489 Cell::new(format!("${:.4}", step.cost_usd)),
490 Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
491 Cell::new(format_optional_datetime(&step.started_at)),
492 Cell::new(format_optional_datetime(&step.completed_at)),
493 ]);
494 }
495
496 table
497}
498
499pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
501 let mut table = base_table();
502 table.set_header(vec!["Name", "Category", "Version", "Paused since"]);
503
504 for wf in workflows {
505 table.add_row(vec![
506 Cell::new(&wf.name),
507 Cell::new(wf.category.as_deref().unwrap_or("-")),
508 Cell::new(wf.version.as_deref().unwrap_or("-")),
509 Cell::new(format_optional_datetime(&wf.paused_at)),
510 ]);
511 }
512
513 table
514}
515
516pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
518 let mut table = base_table();
519 table.set_header(vec!["Field", "Value"]);
520
521 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
522 table.add_row(vec![
523 Cell::new("Description"),
524 Cell::new(&detail.description),
525 ]);
526 table.add_row(vec![
527 Cell::new("Category"),
528 Cell::new(detail.category.as_deref().unwrap_or("-")),
529 ]);
530 table.add_row(vec![
531 Cell::new("Version"),
532 Cell::new(detail.version.as_deref().unwrap_or("-")),
533 ]);
534 table.add_row(vec![Cell::new("Priority"), Cell::new(detail.priority)]);
535 if let Some(paused_at) = &detail.paused_at {
536 table.add_row(vec![
537 Cell::new("Paused since"),
538 Cell::new(format_datetime(paused_at)),
539 ]);
540 }
541
542 if !detail.sub_workflows.is_empty() {
543 let names: Vec<&str> = detail
544 .sub_workflows
545 .iter()
546 .map(|s| s.name.as_str())
547 .collect();
548 table.add_row(vec![
549 Cell::new("Sub-workflows"),
550 Cell::new(names.join(", ")),
551 ]);
552 }
553
554 table
555}
556
557pub fn execution_plan_tree(plan: &ExecutionPlanResponse) -> String {
575 let mut lines = Vec::new();
576
577 let mut header = format!("workflow {}", plan.workflow);
578 if let Some(total) = plan.estimated_duration_ms {
579 header.push_str(&format!(" estimated ~{}", format_duration_ms(total)));
580 }
581 lines.push(header);
582
583 let mut current_group: Option<&str> = None;
584 for (index, step) in plan.steps.iter().enumerate() {
585 let group = step.parallel_group.as_deref();
586 if group != current_group {
587 if let Some(name) = group {
588 lines.push(format!("{}├─ {name}", indent(depth_of(step))));
589 }
590 current_group = group;
591 }
592
593 let extra = if group.is_some() { " " } else { "" };
594 let branch = if is_last_at_depth(plan, index) {
595 "└─ "
596 } else {
597 "├─ "
598 };
599 lines.push(format!(
600 "{}{extra}{branch}{}",
601 indent(depth_of(step)),
602 step_label(step)
603 ));
604 }
605
606 if plan.truncated {
607 let reason = plan
608 .incomplete_reason
609 .as_deref()
610 .unwrap_or("the plan was cut short");
611 lines.push(format!("plan incomplete: {reason}"));
612 }
613
614 lines.join("\n")
615}
616
617fn indent(depth: usize) -> String {
619 " ".repeat(depth)
620}
621
622fn depth_of(step: &PlannedStepResponse) -> usize {
624 usize::try_from(step.depth).unwrap_or(0)
625}
626
627fn is_last_at_depth(plan: &ExecutionPlanResponse, index: usize) -> bool {
629 let depth = plan.steps[index].depth;
630 !plan.steps[index + 1..].iter().any(|s| s.depth == depth)
631}
632
633fn step_label(step: &PlannedStepResponse) -> String {
635 let mut label = format!("{} [{}]", step.name, step.kind);
636
637 if let Some(ms) = step.estimated_duration_ms {
638 label.push_str(&format!(" ~{}", format_duration_ms(ms)));
639 }
640
641 if let Some(condition) = &step.condition {
642 let suffix = match condition.state.as_str() {
643 "evaluated" => format!(
644 " (when {} = {})",
645 condition.expression.as_deref().unwrap_or("?"),
646 condition.value.unwrap_or(false)
647 ),
648 "skipped" => format!(
649 " (skipped: {})",
650 condition.reason.as_deref().unwrap_or("no reason given")
651 ),
652 _ => format!(
653 " (condition unevaluable: {})",
654 condition.expression.as_deref().unwrap_or("?")
655 ),
656 };
657 label.push_str(&suffix);
658 }
659
660 label
661}
662
663pub fn render_execution_plan<W: Write>(
669 writer: &mut W,
670 json_mode: bool,
671 response: &ApiResponse<ExecutionPlanResponse>,
672) -> Result<()> {
673 if json_mode {
674 let json = to_string_pretty(response)?;
675 writeln!(writer, "{json}")?;
676 } else {
677 writeln!(writer, "{}", execution_plan_tree(&response.data))?;
678 }
679 Ok(())
680}
681
682pub fn stats_table(stats: &StatsResponse) -> Table {
684 let mut table = base_table();
685 table.set_header(vec!["Metric", "Value"]);
686
687 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
688 table.add_row(vec![
689 Cell::new("Completed"),
690 Cell::new(stats.completed_runs).fg(Color::Green),
691 ]);
692 table.add_row(vec![
693 Cell::new("Failed"),
694 Cell::new(stats.failed_runs).fg(Color::Red),
695 ]);
696 table.add_row(vec![
697 Cell::new("Cancelled"),
698 Cell::new(stats.cancelled_runs).fg(Color::Grey),
699 ]);
700 table.add_row(vec![
701 Cell::new("Active"),
702 Cell::new(stats.active_runs).fg(Color::Blue),
703 ]);
704 table.add_row(vec![
705 Cell::new("Awaiting approval"),
706 Cell::new(stats.awaiting_approval_runs).fg(Color::Magenta),
707 ]);
708 table.add_row(vec![
709 Cell::new("Success rate"),
710 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
711 ]);
712 table.add_row(vec![
713 Cell::new("Total cost"),
714 Cell::new(format!("${:.4}", stats.total_cost_usd)),
715 ]);
716 table.add_row(vec![
717 Cell::new("Total duration"),
718 Cell::new(format_duration_ms(stats.total_duration_ms)),
719 ]);
720
721 table
722}
723
724pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
726 let mut table = base_table();
727 table.set_header(vec![
728 "Time",
729 "Completed",
730 "Warning",
731 "Failed",
732 "Cancelled",
733 "Active",
734 "Success %",
735 "Avg (ms)",
736 "P95 (ms)",
737 "Cost",
738 ]);
739
740 for bucket in &history.buckets {
741 let active = bucket.pending
742 + bucket.running
743 + bucket.retrying
744 + bucket.awaiting_approval
745 + bucket.sleeping
746 + bucket.paused;
747 table.add_row(vec![
748 Cell::new(bucket.time),
749 Cell::new(bucket.completed).fg(Color::Green),
750 Cell::new(bucket.warning).fg(Color::Yellow),
751 Cell::new(bucket.failed).fg(Color::Red),
752 Cell::new(bucket.cancelled).fg(Color::Grey),
753 Cell::new(active).fg(Color::Blue),
754 Cell::new(format_success_rate(bucket.success_rate_percent)),
755 Cell::new(bucket.avg_duration_ms),
756 Cell::new(bucket.p95_duration_ms),
757 Cell::new(format!("${:.4}", bucket.total_cost_usd)),
758 ]);
759 }
760
761 table
762}
763
764fn format_success_rate(rate: Option<f64>) -> String {
766 rate.map_or_else(|| "-".to_string(), |r| format!("{r:.1}%"))
767}
768
769fn format_versions(versions: &[i32]) -> String {
771 if versions.is_empty() {
772 return "-".to_string();
773 }
774 versions
775 .iter()
776 .map(|v| v.to_string())
777 .collect::<Vec<_>>()
778 .join(", ")
779}
780
781#[derive(Debug, Serialize)]
795pub struct Deleted {
796 pub kind: &'static str,
798 pub id: String,
800 pub deleted: bool,
802}
803
804impl Deleted {
805 pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
807 Self {
808 kind,
809 id: id.into(),
810 deleted: true,
811 }
812 }
813}
814
815pub fn deleted_table(deleted: &Deleted) -> Table {
817 let mut table = base_table();
818 table.set_header(vec!["Deleted", "ID"]);
819 table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
820 table
821}
822
823pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
840 let deleted = Deleted::new(kind, id);
841 print_output(json_mode, &deleted, || deleted_table(&deleted))
842}
843
844pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
849 let mut table = base_table();
850 table.set_header(vec!["Key", "Created", "Updated"]);
851
852 for secret in secrets {
853 table.add_row(vec![
854 Cell::new(&secret.key),
855 Cell::new(format_datetime(&secret.created_at)),
856 Cell::new(format_datetime(&secret.updated_at)),
857 ]);
858 }
859
860 table
861}
862
863fn window_percent(windows: &[AccountWindowResponse], name: &str) -> String {
865 windows
866 .iter()
867 .find(|w| w.window == name && w.model_scope.is_none())
868 .map_or_else(
869 || "-".to_string(),
870 |w| format!("{:.0}%", w.utilization * 100.0),
871 )
872}
873
874fn account_state_color(state: &AccountState) -> Color {
876 match state {
877 AccountState::Ok => Color::Green,
878 AccountState::NearLimit => Color::Yellow,
879 AccountState::Limited | AccountState::TokenInvalid => Color::Red,
880 AccountState::NeverUsed => Color::Grey,
881 }
882}
883
884pub fn provider_accounts_table(accounts: &[ProviderAccountResponse]) -> Table {
886 let mut table = base_table();
887 table.set_header(vec![
888 "Name", "Kind", "State", "Enabled", "Priority", "Tags", "5h", "7d", "Expires",
889 ]);
890
891 for account in accounts {
892 table.add_row(vec![
893 Cell::new(&account.name),
894 Cell::new(&account.kind),
895 Cell::new(account.state.to_string()).fg(account_state_color(&account.state)),
896 Cell::new(if account.enabled { "yes" } else { "no" }),
897 Cell::new(account.priority).set_alignment(CellAlignment::Right),
898 Cell::new(account.tags.join(", ")),
899 Cell::new(window_percent(&account.windows, "five_hour"))
900 .set_alignment(CellAlignment::Right),
901 Cell::new(window_percent(&account.windows, "seven_day"))
902 .set_alignment(CellAlignment::Right),
903 Cell::new(format_datetime(&account.expires_at)),
904 ]);
905 }
906
907 table
908}
909
910pub fn provider_account_windows_table(windows: &[AccountWindowResponse]) -> Table {
912 let mut table = base_table();
913 table.set_header(vec![
914 "Window", "Scope", "Used", "Status", "Resets", "Observed",
915 ]);
916
917 for window in windows {
918 let color = match window.status {
919 AccountWindowStatus::Allowed => Color::Green,
920 AccountWindowStatus::AllowedWarning => Color::Yellow,
921 AccountWindowStatus::Rejected => Color::Red,
922 };
923 table.add_row(vec![
924 Cell::new(&window.window),
925 Cell::new(window.model_scope.as_deref().unwrap_or("-")),
926 Cell::new(format!("{:.0}%", window.utilization * 100.0))
927 .set_alignment(CellAlignment::Right),
928 Cell::new(window.status.to_string()).fg(color),
929 Cell::new(format_optional_datetime(&window.resets_at)),
930 Cell::new(format_datetime(&window.observed_at)),
931 ]);
932 }
933
934 table
935}
936
937fn format_scopes(scopes: &[ApiKeyScope]) -> String {
939 scopes
940 .iter()
941 .map(ToString::to_string)
942 .collect::<Vec<_>>()
943 .join(", ")
944}
945
946pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
948 let mut table = base_table();
949 table.set_header(vec!["Property", "Versions"]);
950
951 table.add_row(vec![
952 Cell::new("Active"),
953 Cell::new(status.active).fg(Color::Green),
954 ]);
955 table.add_row(vec![
956 Cell::new("Configured"),
957 Cell::new(format_versions(&status.configured)),
958 ]);
959 table.add_row(vec![
960 Cell::new("In use"),
961 Cell::new(format_versions(&status.in_use)),
962 ]);
963 table.add_row(vec![
964 Cell::new("Missing"),
965 Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
966 Color::Grey
967 } else {
968 Color::Red
969 }),
970 ]);
971 table.add_row(vec![
972 Cell::new("Retirable"),
973 Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
974 Color::Grey
975 } else {
976 Color::Yellow
977 }),
978 ]);
979
980 table
981}
982
983pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
987 let mut table = base_table();
988 table.set_header(vec![
989 "ID",
990 "Name",
991 "Prefix",
992 "Scopes",
993 "Active",
994 "Rate limit",
995 "Last used",
996 "Expires",
997 "Created",
998 ]);
999
1000 for key in keys {
1001 let active = Cell::new(if key.is_active { "yes" } else { "no" })
1002 .fg(if key.is_active {
1003 Color::Green
1004 } else {
1005 Color::Grey
1006 })
1007 .set_alignment(CellAlignment::Center);
1008
1009 let rate_limit = key
1010 .rate_limit_override
1011 .map(|v| v.to_string())
1012 .unwrap_or_else(|| "-".to_string());
1013
1014 table.add_row(vec![
1015 Cell::new(key.id),
1016 Cell::new(&key.name),
1017 Cell::new(&key.key_prefix),
1018 Cell::new(format_scopes(&key.scopes)),
1019 active,
1020 Cell::new(rate_limit),
1021 Cell::new(format_optional_datetime(&key.last_used_at)),
1022 Cell::new(format_optional_datetime(&key.expires_at)),
1023 Cell::new(format_datetime(&key.created_at)),
1024 ]);
1025 }
1026
1027 table
1028}
1029
1030pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
1036 let mut table = base_table();
1037 table.set_header(vec!["Field", "Value"]);
1038
1039 table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
1040 table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
1041 table.add_row(vec![
1042 Cell::new("Key"),
1043 Cell::new(&key.key).fg(Color::Yellow),
1044 ]);
1045 table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
1046 table.add_row(vec![
1047 Cell::new("Scopes"),
1048 Cell::new(format_scopes(&key.scopes)),
1049 ]);
1050 if let Some(override_val) = key.rate_limit_override {
1051 table.add_row(vec![
1052 Cell::new("Rate limit"),
1053 Cell::new(format!("{override_val} req/min")),
1054 ]);
1055 }
1056 table.add_row(vec![
1057 Cell::new("Expires"),
1058 Cell::new(format_optional_datetime(&key.expires_at)),
1059 ]);
1060 table.add_row(vec![
1061 Cell::new("Created"),
1062 Cell::new(format_datetime(&key.created_at)),
1063 ]);
1064
1065 table
1066}
1067
1068pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
1070 let mut table = base_table();
1071 table.set_header(vec!["Value", "Label", "Description"]);
1072
1073 for scope in scopes {
1074 table.add_row(vec![
1075 Cell::new(&scope.value),
1076 Cell::new(&scope.label),
1077 Cell::new(&scope.description),
1078 ]);
1079 }
1080
1081 table
1082}
1083
1084pub fn users_table(users: &[UserResponse]) -> Table {
1086 let mut table = base_table();
1087 table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
1088
1089 for user in users {
1090 let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
1091 .fg(if user.is_admin {
1092 Color::Magenta
1093 } else {
1094 Color::Grey
1095 })
1096 .set_alignment(CellAlignment::Center);
1097
1098 table.add_row(vec![
1099 Cell::new(user.id),
1100 Cell::new(&user.username),
1101 Cell::new(&user.email),
1102 admin,
1103 Cell::new(format_datetime(&user.created_at)),
1104 ]);
1105 }
1106
1107 table
1108}
1109
1110pub fn user_groups_table(resp: &UserGroupsResponse) -> Table {
1112 let mut table = base_table();
1113 table.set_header(vec!["User ID", "Groups"]);
1114
1115 let groups = if resp.groups.is_empty() {
1116 "-".to_string()
1117 } else {
1118 resp.groups.join(", ")
1119 };
1120 table.add_row(vec![Cell::new(resp.user_id), Cell::new(groups)]);
1121
1122 table
1123}
1124
1125pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
1127 let (ra, rb) = (&a.run, &b.run);
1128 let mut table = base_table();
1129 table.set_header(vec![
1130 "Field",
1131 &format!("Run {}", short_id(ra.id)),
1132 &format!("Run {}", short_id(rb.id)),
1133 ]);
1134
1135 let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
1136 let hl = va != vb;
1137 vec![
1138 Cell::new(f),
1139 if hl {
1140 Cell::new(&va).fg(Color::Yellow)
1141 } else {
1142 Cell::new(&va)
1143 },
1144 if hl {
1145 Cell::new(&vb).fg(Color::Yellow)
1146 } else {
1147 Cell::new(&vb)
1148 },
1149 ]
1150 };
1151
1152 table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
1153 table.add_row(row(
1154 "Duration",
1155 format_duration_ms(ra.duration_ms),
1156 format_duration_ms(rb.duration_ms),
1157 ));
1158 table.add_row(row(
1159 "Cost",
1160 format_cost(ra.cost_usd, ra.max_cost_usd),
1161 format_cost(rb.cost_usd, rb.max_cost_usd),
1162 ));
1163 table.add_row(row(
1164 "Started",
1165 format_optional_datetime(&ra.started_at),
1166 format_optional_datetime(&rb.started_at),
1167 ));
1168 table.add_row(row(
1169 "Completed",
1170 format_optional_datetime(&ra.completed_at),
1171 format_optional_datetime(&rb.completed_at),
1172 ));
1173 table.add_row(row(
1174 "Error",
1175 ra.error.clone().unwrap_or("-".into()),
1176 rb.error.clone().unwrap_or("-".into()),
1177 ));
1178 if a.payload != b.payload {
1179 table.add_row(row(
1180 "Payload",
1181 serde_json::to_string(&a.payload).unwrap_or_default(),
1182 serde_json::to_string(&b.payload).unwrap_or_default(),
1183 ));
1184 }
1185 for i in 0..a.steps.len().max(b.steps.len()) {
1186 let (sa, sb) = (a.steps.get(i), b.steps.get(i));
1187 let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
1188 table.add_row(row(
1189 &format!("{name} status"),
1190 sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
1191 sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
1192 ));
1193 table.add_row(row(
1194 &format!("{name} duration"),
1195 sa.map(|s| format_duration_ms(s.duration_ms))
1196 .unwrap_or("-".into()),
1197 sb.map(|s| format_duration_ms(s.duration_ms))
1198 .unwrap_or("-".into()),
1199 ));
1200 table.add_row(row(
1201 &format!("{name} cost"),
1202 sa.map(|s| format!("${:.4}", s.cost_usd))
1203 .unwrap_or("-".into()),
1204 sb.map(|s| format!("${:.4}", s.cost_usd))
1205 .unwrap_or("-".into()),
1206 ));
1207 }
1208 table
1209}
1210
1211fn short_id(id: Uuid) -> String {
1213 id.to_string()
1214 .split('-')
1215 .next()
1216 .unwrap_or_default()
1217 .to_string()
1218}
1219
1220fn format_optional_id(id: &Option<Uuid>) -> String {
1222 id.map_or_else(|| "-".to_string(), short_id)
1223}
1224
1225pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
1230 let mut table = base_table();
1231 table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
1232
1233 for entry in entries {
1234 table.add_row(vec![
1235 Cell::new(short_id(entry.id)),
1236 Cell::new(entry.event_type.to_string()),
1237 Cell::new(format_optional_id(&entry.run_id)),
1238 Cell::new(format_optional_id(&entry.step_id)),
1239 Cell::new(format_optional_id(&entry.user_id)),
1240 Cell::new(format_datetime(&entry.created_at)),
1241 ]);
1242 }
1243
1244 table
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use std::collections::HashMap;
1250 use std::slice;
1251
1252 use ironflow_sdk::types::{
1253 ApiKeyScope, ConditionResponse, CreatedBy, CreatedByKind, EventKind, TriggerKind,
1254 };
1255 use serde_json::{Map, Value, from_value, json};
1256
1257 use super::*;
1258
1259 fn run_fixture(created_by: CreatedBy) -> RunResponse {
1261 let now = Utc::now();
1262 RunResponse {
1263 id: Uuid::now_v7(),
1264 workflow_name: "deploy".to_string(),
1265 status: RunStatus::Completed,
1266 trigger: TriggerKind::Api,
1267 error: None,
1268 retry_count: 0,
1269 max_retries: 0,
1270 lease_recoveries: 0,
1271 cost_usd: 0.0,
1272 duration_ms: 0,
1273 created_at: now,
1274 updated_at: now,
1275 started_at: None,
1276 completed_at: None,
1277 handler_version: None,
1278 labels: HashMap::new(),
1279 scheduled_at: None,
1280 capacity_wait_kind: None,
1281 resume_status: None,
1282 created_by,
1283 idempotency_key: None,
1284 concurrency_key: None,
1285 priority: Some(0),
1286 concurrency_limits: Vec::new(),
1287 max_cost_usd: None,
1288 output: None,
1289 worker_tags: Vec::new(),
1290 }
1291 }
1292
1293 #[test]
1294 fn format_success_rate_renders_dash_when_absent() {
1295 assert_eq!(format_success_rate(None), "-");
1296 }
1297
1298 #[test]
1299 fn format_success_rate_renders_one_decimal() {
1300 assert_eq!(format_success_rate(Some(100.0)), "100.0%");
1301 assert_eq!(format_success_rate(Some(200.0 / 3.0)), "66.7%");
1302 assert_eq!(format_success_rate(Some(0.0)), "0.0%");
1303 }
1304
1305 #[test]
1306 fn format_cost_without_cap_shows_amount_only() {
1307 assert_eq!(format_cost(0.1234, None), "$0.1234");
1308 }
1309
1310 #[test]
1311 fn format_cost_with_cap_shows_both_amounts() {
1312 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
1313 }
1314
1315 #[test]
1316 fn cost_color_is_absent_without_a_cap() {
1317 assert_eq!(cost_color(999.0, None), None);
1318 }
1319
1320 #[test]
1321 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
1322 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));
1325 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
1328
1329 #[test]
1330 fn cost_color_handles_a_zero_cap() {
1331 assert_eq!(cost_color(0.0, Some(0.0)), None);
1332 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
1333 }
1334
1335 fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
1336 ArtifactResponse {
1337 id: Uuid::now_v7(),
1338 step_id: Uuid::now_v7(),
1339 name: name.to_string(),
1340 content_type: "text/plain".to_string(),
1341 size_bytes,
1342 sha256: "0".repeat(64),
1343 created_at: Utc::now(),
1344 }
1345 }
1346
1347 #[test]
1348 fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
1349 assert_eq!(format_bytes(0), "0 B");
1350 assert_eq!(format_bytes(1023), "1023 B");
1351 }
1352
1353 #[test]
1354 fn format_bytes_switches_units_at_each_boundary() {
1355 assert_eq!(format_bytes(1024), "1.0 KB");
1356 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1357 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1358 }
1359
1360 #[test]
1361 fn format_bytes_drops_the_decimal_past_ten() {
1362 assert_eq!(format_bytes(145_408), "142 KB");
1363 }
1364
1365 #[test]
1366 fn format_artifacts_shows_a_dash_when_there_are_none() {
1367 assert_eq!(format_artifacts(&[]), "-");
1368 }
1369
1370 #[test]
1371 fn format_artifacts_shows_the_count_and_total_size() {
1372 let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
1373 assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
1374 }
1375
1376 #[test]
1377 fn format_duration_ms_millis() {
1378 assert_eq!(format_duration_ms(500), "500ms");
1379 assert_eq!(format_duration_ms(0), "0ms");
1380 }
1381
1382 #[test]
1383 fn format_duration_ms_seconds() {
1384 assert_eq!(format_duration_ms(5000), "5s");
1385 assert_eq!(format_duration_ms(59000), "59s");
1386 }
1387
1388 #[test]
1389 fn format_duration_ms_minutes() {
1390 assert_eq!(format_duration_ms(60000), "1m 0s");
1391 assert_eq!(format_duration_ms(125000), "2m 5s");
1392 }
1393
1394 #[test]
1395 fn format_duration_ms_hours() {
1396 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
1397 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
1398 }
1399
1400 #[test]
1401 fn format_sla_without_a_deadline_is_a_dash() {
1402 assert_eq!(format_remaining_secs(None), "-");
1403 }
1404
1405 #[test]
1406 fn format_sla_reports_an_elapsed_deadline_as_expired() {
1407 assert_eq!(format_remaining_secs(Some(0)), "expired");
1408 assert_eq!(format_remaining_secs(Some(-30)), "expired");
1409 }
1410
1411 #[test]
1412 fn format_sla_uses_coarse_units() {
1413 assert_eq!(format_remaining_secs(Some(45)), "45s");
1414 assert_eq!(format_remaining_secs(Some(59)), "59s");
1415 assert_eq!(format_remaining_secs(Some(60)), "1m");
1416 assert_eq!(format_remaining_secs(Some(750)), "12m 30s");
1417 assert_eq!(format_remaining_secs(Some(3599)), "59m 59s");
1418 assert_eq!(format_remaining_secs(Some(3600)), "1h");
1419 assert_eq!(format_remaining_secs(Some(4320)), "1h 12m");
1420 }
1421
1422 #[test]
1423 fn sla_has_no_colour_without_a_deadline() {
1424 assert_eq!(remaining_color(None, None), None);
1425 assert_eq!(remaining_color(None, Some(3600)), None);
1426 }
1427
1428 #[test]
1429 fn sla_turns_red_once_expired() {
1430 assert_eq!(remaining_color(Some(0), Some(3600)), Some(Color::Red));
1431 assert_eq!(remaining_color(Some(-1), None), Some(Color::Red));
1432 }
1433
1434 #[test]
1435 fn sla_turns_yellow_in_the_last_tenth_of_the_window() {
1436 assert_eq!(remaining_color(Some(359), Some(3600)), Some(Color::Yellow));
1437 assert_eq!(remaining_color(Some(360), Some(3600)), None);
1438 assert_eq!(remaining_color(Some(3000), Some(3600)), None);
1439 }
1440
1441 #[test]
1442 fn sla_has_no_colour_without_a_measurable_window() {
1443 assert_eq!(remaining_color(Some(120), None), None);
1444 assert_eq!(remaining_color(Some(120), Some(0)), None);
1445 }
1446
1447 #[test]
1448 fn format_optional_datetime_none() {
1449 assert_eq!(format_optional_datetime(&None), "-");
1450 }
1451
1452 #[test]
1453 fn format_optional_datetime_some() {
1454 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
1455 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
1456 }
1457
1458 #[test]
1459 fn status_colors_are_distinct() {
1460 let statuses = [
1461 RunStatus::Completed,
1462 RunStatus::Failed,
1463 RunStatus::Running,
1464 RunStatus::Pending,
1465 RunStatus::Cancelled,
1466 RunStatus::AwaitingApproval,
1467 RunStatus::Retrying,
1468 ];
1469
1470 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
1471 for (i, c1) in colors.iter().enumerate() {
1472 for (j, c2) in colors.iter().enumerate() {
1473 if i != j {
1474 assert_ne!(c1, c2, "status colors must be distinct");
1475 }
1476 }
1477 }
1478 }
1479
1480 #[test]
1481 fn empty_runs_table_has_header() {
1482 let table = runs_table(&[]);
1483 let output = table.to_string();
1484 assert!(output.contains("ID"));
1485 assert!(output.contains("Workflow"));
1486 assert!(output.contains("Status"));
1487 assert!(output.contains("Triggered by"));
1488 assert!(output.contains("Priority"));
1489 }
1490
1491 #[test]
1492 fn runs_table_renders_the_author_label() {
1493 let run = run_fixture(CreatedBy {
1494 kind: CreatedByKind::ApiKey,
1495 id: Some(Uuid::now_v7()),
1496 label: "ci-deploy (alice)".to_string(),
1497 });
1498
1499 let output = runs_table(slice::from_ref(&run)).to_string();
1500 assert!(
1501 output.contains("ci-deploy (alice)"),
1502 "author missing from:\n{output}"
1503 );
1504 }
1505
1506 #[test]
1507 fn format_priority_renders_the_value_or_a_dash() {
1508 assert_eq!(format_priority(Some(-40)), "-40");
1509 assert_eq!(format_priority(Some(0)), "0");
1510 assert_eq!(format_priority(None), "-");
1511 }
1512
1513 #[test]
1514 fn runs_table_renders_the_priority() {
1515 let mut run = run_fixture(CreatedBy {
1516 kind: CreatedByKind::System,
1517 id: None,
1518 label: "api".to_string(),
1519 });
1520 run.priority = Some(-73);
1521
1522 let output = runs_table(slice::from_ref(&run)).to_string();
1523 assert!(output.contains("-73"), "priority missing from:\n{output}");
1524 }
1525
1526 #[test]
1527 fn run_detail_table_shows_the_priority() {
1528 let mut run = run_fixture(CreatedBy {
1529 kind: CreatedByKind::System,
1530 id: None,
1531 label: "api".to_string(),
1532 });
1533 run.priority = Some(64);
1534 let detail = RunDetailResponse {
1535 run,
1536 steps: Vec::new(),
1537 payload: Value::Object(Map::new()),
1538 active_descendant_count: 0,
1539 worker_routing: None,
1540 };
1541
1542 let output = run_detail_table(&detail).to_string();
1543 assert!(output.contains("Priority"), "row missing from:\n{output}");
1544 assert!(output.contains("64"), "priority missing from:\n{output}");
1545 }
1546
1547 fn detail_with_lease_recoveries(lease_recoveries: i32) -> RunDetailResponse {
1548 let mut run = run_fixture(CreatedBy {
1549 kind: CreatedByKind::System,
1550 id: None,
1551 label: "api".to_string(),
1552 });
1553 run.lease_recoveries = lease_recoveries;
1554 RunDetailResponse {
1555 run,
1556 steps: Vec::new(),
1557 payload: Value::Object(Map::new()),
1558 active_descendant_count: 0,
1559 worker_routing: None,
1560 }
1561 }
1562
1563 #[test]
1564 fn run_detail_table_shows_the_lease_recoveries() {
1565 let output = run_detail_table(&detail_with_lease_recoveries(2)).to_string();
1566 let row = output
1567 .lines()
1568 .find(|line| line.contains("Lease recoveries"))
1569 .unwrap_or_else(|| panic!("row missing from:\n{output}"));
1570 assert!(row.contains('2'), "value missing from row: {row}");
1571 }
1572
1573 #[test]
1574 fn run_detail_table_shows_zero_lease_recoveries() {
1575 let output = run_detail_table(&detail_with_lease_recoveries(0)).to_string();
1576 let row = output
1577 .lines()
1578 .find(|line| line.contains("Lease recoveries"))
1579 .unwrap_or_else(|| panic!("row missing from:\n{output}"));
1580 assert!(row.contains('0'), "value missing from row: {row}");
1581 }
1582
1583 #[test]
1584 fn workflow_detail_table_shows_the_priority() {
1585 let detail: WorkflowDetailResponse = from_value(json!({
1586 "name": "prio-workflow",
1587 "description": "A workflow",
1588 "source_code": null,
1589 "sub_workflows": [],
1590 "category": null,
1591 "version": null,
1592 "priority": -40,
1593 }))
1594 .unwrap();
1595
1596 let output = workflow_detail_table(&detail).to_string();
1597 assert!(output.contains("Priority"), "row missing from:\n{output}");
1598 assert!(output.contains("-40"), "priority missing from:\n{output}");
1599 }
1600
1601 #[test]
1602 fn run_detail_table_renders_the_run_output() {
1603 let mut run = run_fixture(CreatedBy {
1604 kind: CreatedByKind::System,
1605 id: None,
1606 label: "cron".to_string(),
1607 });
1608 run.output = Some(json!({"verdict": "approved"}));
1609 let detail = RunDetailResponse {
1610 run,
1611 steps: Vec::new(),
1612 payload: Value::Object(Map::new()),
1613 active_descendant_count: 0,
1614 worker_routing: None,
1615 };
1616
1617 let output = run_detail_table(&detail).to_string();
1618 assert!(
1619 output.contains("Output"),
1620 "output row missing from:\n{output}"
1621 );
1622 assert!(output.contains(r#"{"verdict":"approved"}"#), "{output}");
1623 }
1624
1625 #[test]
1626 fn run_detail_table_has_no_output_row_without_an_output() {
1627 let detail = RunDetailResponse {
1628 run: run_fixture(CreatedBy {
1629 kind: CreatedByKind::System,
1630 id: None,
1631 label: "cron".to_string(),
1632 }),
1633 steps: Vec::new(),
1634 payload: Value::Object(Map::new()),
1635 active_descendant_count: 0,
1636 worker_routing: None,
1637 };
1638
1639 let output = run_detail_table(&detail).to_string();
1640 assert!(!output.contains("Output"), "{output}");
1641 }
1642
1643 #[test]
1644 fn run_detail_table_renders_the_author_label() {
1645 let detail = RunDetailResponse {
1646 run: run_fixture(CreatedBy {
1647 kind: CreatedByKind::System,
1648 id: None,
1649 label: "/hooks/github".to_string(),
1650 }),
1651 steps: Vec::new(),
1652 payload: Value::Object(Map::new()),
1653 active_descendant_count: 0,
1654 worker_routing: None,
1655 };
1656
1657 let output = run_detail_table(&detail).to_string();
1658 assert!(output.contains("Triggered by"));
1659 assert!(
1660 output.contains("/hooks/github"),
1661 "author missing from:\n{output}"
1662 );
1663 }
1664
1665 #[test]
1666 fn format_concurrency_limits_lists_each_group_with_its_limit() {
1667 let limits = [
1668 ConcurrencyLimit {
1669 group: "repo:acme".to_string(),
1670 limit: 2,
1671 },
1672 ConcurrencyLimit {
1673 group: "tenant:42".to_string(),
1674 limit: 1,
1675 },
1676 ];
1677 assert_eq!(
1678 format_concurrency_limits(&limits),
1679 "repo:acme (2), tenant:42 (1)"
1680 );
1681 }
1682
1683 #[test]
1684 fn run_detail_table_shows_concurrency_groups_only_when_present() {
1685 let mut detail = RunDetailResponse {
1686 run: run_fixture(CreatedBy {
1687 kind: CreatedByKind::System,
1688 id: None,
1689 label: "api".to_string(),
1690 }),
1691 steps: Vec::new(),
1692 payload: Value::Object(Map::new()),
1693 active_descendant_count: 0,
1694 worker_routing: None,
1695 };
1696 let output = run_detail_table(&detail).to_string();
1697 assert!(
1698 !output.contains("Concurrency groups"),
1699 "unexpected row in:\n{output}"
1700 );
1701
1702 detail.run.concurrency_limits = vec![ConcurrencyLimit {
1703 group: "repo:acme".to_string(),
1704 limit: 2,
1705 }];
1706 let output = run_detail_table(&detail).to_string();
1707 assert!(
1708 output.contains("Concurrency groups"),
1709 "row missing from:\n{output}"
1710 );
1711 assert!(
1712 output.contains("repo:acme (2)"),
1713 "group missing from:\n{output}"
1714 );
1715 }
1716
1717 #[test]
1718 fn run_detail_table_shows_the_capacity_wait_only_when_waiting() {
1719 let mut detail = RunDetailResponse {
1720 run: run_fixture(CreatedBy {
1721 kind: CreatedByKind::System,
1722 id: None,
1723 label: "api".to_string(),
1724 }),
1725 steps: Vec::new(),
1726 payload: Value::Object(Map::new()),
1727 active_descendant_count: 0,
1728 worker_routing: None,
1729 };
1730 let output = run_detail_table(&detail).to_string();
1731 assert!(
1732 !output.contains("Waiting for capacity"),
1733 "unexpected row in:\n{output}"
1734 );
1735
1736 let wake_at = Utc::now();
1737 detail.run.status = RunStatus::Sleeping;
1738 detail.run.scheduled_at = Some(wake_at);
1739 detail.run.capacity_wait_kind = Some("claude_subscription".to_string());
1740 let output = run_detail_table(&detail).to_string();
1741 assert!(
1742 output.contains("Waiting for capacity"),
1743 "row missing from:\n{output}"
1744 );
1745 let expected = format!(
1746 "claude_subscription, resumes at {}",
1747 format_datetime(&wake_at)
1748 );
1749 assert!(
1750 output.contains(&expected),
1751 "{expected} missing from:\n{output}"
1752 );
1753 }
1754
1755 #[test]
1756 fn routing_warning_covers_each_case() {
1757 let none_seen = WorkerRouting {
1758 seen_workers: 0,
1759 eligible_workers: 0,
1760 };
1761 let warning = routing_warning(&none_seen);
1762 assert_eq!(warning, Some("No worker seen recently"));
1763
1764 let none_eligible = WorkerRouting {
1765 seen_workers: 3,
1766 eligible_workers: 0,
1767 };
1768 let warning = routing_warning(&none_eligible).expect("a warning");
1769 assert!(warning.starts_with("No eligible worker seen"), "{warning}");
1770
1771 let eligible = WorkerRouting {
1772 seen_workers: 3,
1773 eligible_workers: 1,
1774 };
1775 assert_eq!(routing_warning(&eligible), None);
1776 }
1777
1778 #[test]
1779 fn run_detail_table_shows_worker_tags_only_when_present() {
1780 let mut detail = RunDetailResponse {
1781 run: run_fixture(CreatedBy {
1782 kind: CreatedByKind::System,
1783 id: None,
1784 label: "api".to_string(),
1785 }),
1786 steps: Vec::new(),
1787 payload: Value::Object(Map::new()),
1788 active_descendant_count: 0,
1789 worker_routing: None,
1790 };
1791 let output = run_detail_table(&detail).to_string();
1792 assert!(
1793 !output.contains("Worker tags"),
1794 "unexpected row in:\n{output}"
1795 );
1796
1797 detail.run.worker_tags = vec!["gpu".to_string(), "region:eu".to_string()];
1798 let output = run_detail_table(&detail).to_string();
1799 assert!(
1800 output.contains("Worker tags"),
1801 "row missing from:\n{output}"
1802 );
1803 assert!(
1804 output.contains("gpu, region:eu"),
1805 "tags missing from:\n{output}"
1806 );
1807 }
1808
1809 #[test]
1810 fn run_detail_table_warns_when_no_worker_can_take_the_run() {
1811 let mut detail = RunDetailResponse {
1812 run: run_fixture(CreatedBy {
1813 kind: CreatedByKind::System,
1814 id: None,
1815 label: "api".to_string(),
1816 }),
1817 steps: Vec::new(),
1818 payload: Value::Object(Map::new()),
1819 active_descendant_count: 0,
1820 worker_routing: None,
1821 };
1822 let output = run_detail_table(&detail).to_string();
1823 assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1824
1825 detail.worker_routing = Some(WorkerRouting {
1826 seen_workers: 2,
1827 eligible_workers: 1,
1828 });
1829 let output = run_detail_table(&detail).to_string();
1830 assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1831
1832 detail.worker_routing = Some(WorkerRouting {
1833 seen_workers: 2,
1834 eligible_workers: 0,
1835 });
1836 let output = run_detail_table(&detail).to_string();
1837 assert!(output.contains("Workers"), "row missing from:\n{output}");
1838 assert!(
1839 output.contains("No eligible worker seen"),
1840 "warning missing from:\n{output}"
1841 );
1842 }
1843
1844 #[test]
1845 fn empty_workflows_table_has_header() {
1846 let table = workflows_table(&[]);
1847 let output = table.to_string();
1848 assert!(output.contains("Name"));
1849 assert!(output.contains("Category"));
1850 assert!(output.contains("Paused since"));
1851 }
1852
1853 fn secret_fixture(key: &str) -> SecretResponse {
1856 let now = Utc::now();
1857 SecretResponse {
1858 id: Uuid::now_v7(),
1859 key: key.to_string(),
1860 created_at: now,
1861 updated_at: now,
1862 }
1863 }
1864
1865 #[test]
1866 fn empty_secrets_table_has_header() {
1867 let output = secrets_table(&[]).to_string();
1868 assert!(output.contains("Key"));
1869 assert!(output.contains("Created"));
1870 assert!(output.contains("Updated"));
1871 }
1872
1873 #[test]
1874 fn secrets_table_renders_the_key() {
1875 let secret = secret_fixture("workflows/inbox/gmail_token");
1876 let output = secrets_table(slice::from_ref(&secret)).to_string();
1877 assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1878 }
1879
1880 #[test]
1883 fn a_secret_response_carries_no_value_at_all() {
1884 let secret = secret_fixture("db/password");
1885 let json = serde_json::to_string(&secret).unwrap();
1886 assert!(!json.contains("value"), "{json}");
1887 }
1888
1889 fn api_key_fixture() -> ApiKeyResponse {
1892 ApiKeyResponse {
1893 id: Uuid::now_v7(),
1894 name: "ci-deploy".to_string(),
1895 key_prefix: "ifk_abcd".to_string(),
1896 scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1897 is_active: true,
1898 created_at: Utc::now(),
1899 expires_at: None,
1900 last_used_at: None,
1901 rate_limit_override: None,
1902 }
1903 }
1904
1905 #[test]
1906 fn empty_api_keys_table_has_header() {
1907 let output = api_keys_table(&[]).to_string();
1908 for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1909 assert!(output.contains(header), "missing {header} in {output}");
1910 }
1911 }
1912
1913 #[test]
1914 fn api_keys_table_joins_the_scopes() {
1915 let key = api_key_fixture();
1916 let output = api_keys_table(slice::from_ref(&key)).to_string();
1917 assert!(output.contains("runs_read, runs_write"), "{output}");
1918 assert!(output.contains("ifk_abcd"), "{output}");
1919 }
1920
1921 #[test]
1922 fn created_api_key_table_shows_the_raw_key() {
1923 let created = CreateApiKeyResponse {
1924 id: Uuid::now_v7(),
1925 name: "ci-deploy".to_string(),
1926 key: "ifk_full_raw_key".to_string(),
1927 key_prefix: "ifk_full".to_string(),
1928 scopes: vec![ApiKeyScope::Admin],
1929 created_at: Utc::now(),
1930 expires_at: None,
1931 rate_limit_override: None,
1932 };
1933
1934 let output = created_api_key_table(&created).to_string();
1935 assert!(output.contains("ifk_full_raw_key"), "{output}");
1936 }
1937
1938 #[test]
1939 fn empty_scopes_table_has_header() {
1940 let output = scopes_table(&[]).to_string();
1941 assert!(output.contains("Value"));
1942 assert!(output.contains("Description"));
1943 }
1944
1945 fn user_fixture(is_admin: bool) -> UserResponse {
1948 let now = Utc::now();
1949 UserResponse {
1950 id: Uuid::now_v7(),
1951 username: "alice".to_string(),
1952 email: "alice@example.com".to_string(),
1953 is_admin,
1954 created_at: now,
1955 updated_at: now,
1956 }
1957 }
1958
1959 #[test]
1960 fn empty_users_table_has_header() {
1961 let output = users_table(&[]).to_string();
1962 for header in ["ID", "Username", "Email", "Admin", "Created"] {
1963 assert!(output.contains(header), "missing {header} in {output}");
1964 }
1965 }
1966
1967 #[test]
1968 fn users_table_spells_out_the_role() {
1969 let admin = user_fixture(true);
1970 assert!(
1971 users_table(slice::from_ref(&admin))
1972 .to_string()
1973 .contains("yes")
1974 );
1975
1976 let member = user_fixture(false);
1977 assert!(
1978 users_table(slice::from_ref(&member))
1979 .to_string()
1980 .contains("no")
1981 );
1982 }
1983
1984 #[test]
1985 fn user_groups_table_has_header_and_lists_the_groups() {
1986 let resp = UserGroupsResponse {
1987 user_id: Uuid::now_v7(),
1988 groups: vec!["finance".to_string(), "sre".to_string()],
1989 };
1990 let output = user_groups_table(&resp).to_string();
1991 for header in ["User ID", "Groups"] {
1992 assert!(output.contains(header), "missing {header} in {output}");
1993 }
1994 assert!(output.contains(&resp.user_id.to_string()), "{output}");
1995 assert!(output.contains("finance, sre"), "{output}");
1996 }
1997
1998 #[test]
1999 fn user_groups_table_shows_a_dash_without_groups() {
2000 let resp = UserGroupsResponse {
2001 user_id: Uuid::now_v7(),
2002 groups: Vec::new(),
2003 };
2004 let output = user_groups_table(&resp).to_string();
2005 assert!(output.contains("Groups"), "{output}");
2006 assert!(output.contains(" - "), "{output}");
2007 assert!(!output.contains("finance"), "{output}");
2008 }
2009
2010 #[test]
2013 fn empty_audit_logs_table_has_header() {
2014 let output = audit_logs_table(&[]).to_string();
2015 for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
2016 assert!(output.contains(header), "missing {header} in {output}");
2017 }
2018 }
2019
2020 #[test]
2021 fn audit_logs_table_omits_the_payload() {
2022 let entry = AuditLogEntry {
2023 id: Uuid::now_v7(),
2024 event_type: EventKind::RunCreated,
2025 payload: Value::Object(Map::new()),
2026 run_id: Some(Uuid::now_v7()),
2027 step_id: None,
2028 user_id: None,
2029 created_at: Utc::now(),
2030 };
2031
2032 let output = audit_logs_table(slice::from_ref(&entry)).to_string();
2033 assert!(output.contains("run_created"), "{output}");
2034 assert!(output.contains(" - "), "{output}");
2036 }
2037
2038 #[test]
2039 fn format_optional_id_shortens_and_falls_back() {
2040 assert_eq!(format_optional_id(&None), "-");
2041 let id = Uuid::now_v7();
2042 let short = format_optional_id(&Some(id));
2043 assert_eq!(short, id.to_string().split('-').next().unwrap());
2044 }
2045
2046 #[test]
2049 fn deleted_table_reports_the_kind_and_id() {
2050 let deleted = Deleted::new("secret", "db/password");
2051 let output = deleted_table(&deleted).to_string();
2052 assert!(output.contains("secret"), "{output}");
2053 assert!(output.contains("db/password"), "{output}");
2054
2055 let json = serde_json::to_string(&deleted).unwrap();
2056 assert!(json.contains(r#""deleted":true"#), "{json}");
2057 }
2058
2059 fn planned_step(name: &str, kind: &str, parallel_group: Option<&str>) -> PlannedStepResponse {
2062 PlannedStepResponse {
2063 name: name.to_string(),
2064 kind: kind.to_string(),
2065 workflow: "deploy".to_string(),
2066 depth: 0,
2067 depends_on: Vec::new(),
2068 condition: None,
2069 parallel_group: parallel_group.map(str::to_string),
2070 estimated_duration_ms: None,
2071 }
2072 }
2073
2074 fn plan_fixture(steps: Vec<PlannedStepResponse>) -> ExecutionPlanResponse {
2075 ExecutionPlanResponse {
2076 workflow: "deploy".to_string(),
2077 steps,
2078 estimated_duration_ms: None,
2079 max_depth: 3,
2080 truncated: false,
2081 incomplete_reason: None,
2082 }
2083 }
2084
2085 #[test]
2086 fn execution_plan_tree_lists_step_names_and_kinds() {
2087 let plan = plan_fixture(vec![
2088 planned_step("build", "shell", None),
2089 planned_step("deploy", "shell", None),
2090 ]);
2091
2092 let output = execution_plan_tree(&plan);
2093 assert!(output.contains("workflow deploy"), "{output}");
2094 assert!(output.contains("build [shell]"), "{output}");
2095 assert!(output.contains("deploy [shell]"), "{output}");
2096 }
2097
2098 #[test]
2099 fn execution_plan_tree_prints_a_parallel_group_header_once() {
2100 let plan = plan_fixture(vec![
2101 planned_step("build", "shell", None),
2102 planned_step("test", "shell", Some("parallel-1")),
2103 planned_step("lint", "shell", Some("parallel-1")),
2104 ]);
2105
2106 let output = execution_plan_tree(&plan);
2107 assert_eq!(output.matches("parallel-1").count(), 1, "{output}");
2108 }
2109
2110 #[test]
2111 fn execution_plan_tree_shows_the_estimate_when_present() {
2112 let mut step = planned_step("build", "shell", None);
2113 step.estimated_duration_ms = Some(5000);
2114 let mut plan = plan_fixture(vec![step]);
2115 plan.estimated_duration_ms = Some(5000);
2116
2117 let output = execution_plan_tree(&plan);
2118 assert!(output.contains("estimated ~5s"), "{output}");
2119 assert!(output.contains("build [shell] ~5s"), "{output}");
2120 }
2121
2122 #[test]
2123 fn execution_plan_tree_marks_conditions() {
2124 let mut evaluated = planned_step("deploy-prod", "shell", None);
2125 evaluated.condition = Some(ConditionResponse {
2126 state: "evaluated".to_string(),
2127 expression: Some("env == prod".to_string()),
2128 value: Some(true),
2129 reason: None,
2130 });
2131 let mut skipped = planned_step("deploy-dev", "skip", None);
2132 skipped.condition = Some(ConditionResponse {
2133 state: "skipped".to_string(),
2134 expression: None,
2135 value: None,
2136 reason: Some("not prod".to_string()),
2137 });
2138 let mut unevaluable = planned_step("notify", "http", None);
2139 unevaluable.condition = Some(ConditionResponse {
2140 state: "unevaluable".to_string(),
2141 expression: Some("build succeeded".to_string()),
2142 value: None,
2143 reason: Some("depends on a step output".to_string()),
2144 });
2145
2146 let output = execution_plan_tree(&plan_fixture(vec![evaluated, skipped, unevaluable]));
2147 assert!(output.contains("(when env == prod = true)"), "{output}");
2148 assert!(output.contains("(skipped: not prod)"), "{output}");
2149 assert!(
2150 output.contains("(condition unevaluable: build succeeded)"),
2151 "{output}"
2152 );
2153 }
2154
2155 #[test]
2156 fn execution_plan_tree_reports_an_incomplete_plan() {
2157 let mut plan = plan_fixture(vec![planned_step("build", "shell", None)]);
2158 plan.truncated = true;
2159 plan.incomplete_reason = Some("step cap of 1000 reached".to_string());
2160
2161 let output = execution_plan_tree(&plan);
2162 assert!(
2163 output.contains("plan incomplete: step cap of 1000 reached"),
2164 "{output}"
2165 );
2166 }
2167
2168 #[test]
2169 fn execution_plan_tree_indents_sub_workflow_steps() {
2170 let mut nested = planned_step("child-step", "shell", None);
2171 nested.depth = 1;
2172 let plan = plan_fixture(vec![planned_step("child", "workflow", None), nested]);
2173
2174 let output = execution_plan_tree(&plan);
2175 let nested = output
2176 .lines()
2177 .find(|l| l.contains("child-step"))
2178 .expect("nested line");
2179 assert!(nested.starts_with(" "), "{nested}");
2180 }
2181}