Skip to main content

orch/cli/
dashboard.rs

1use crate::backends::Status;
2use crate::cli;
3use crate::cli::init_task_manager;
4use crate::config;
5use crate::store;
6use crate::store::TaskStatus;
7use crate::store::TaskStore;
8use crate::tmux::TmuxManager;
9use chrono::{DateTime, Duration, Utc};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13/// Simple dashboard command combining task status, active sessions, and recent activity.
14pub async fn dashboard(global: bool, project: Option<String>) -> anyhow::Result<()> {
15    // Use global store when --global flag is set, a project filter is given,
16    // or when no project context is available (running outside a project dir).
17    if global || project.is_some() {
18        return dashboard_from_global_store(project.as_deref()).await;
19    }
20
21    let task_manager = match init_task_manager().await {
22        Ok(tm) => tm,
23        Err(e) => {
24            tracing::debug!("no project context available, falling back to global store: {e:#}");
25            return dashboard_from_global_store(None).await;
26        }
27    };
28
29    let statuses = [
30        Status::New,
31        Status::Routed,
32        Status::InProgress,
33        Status::InReview,
34        Status::Approved,
35        Status::Done,
36        Status::NeedsReview,
37        Status::Blocked,
38        Status::Waiting,
39    ];
40
41    // Fetch all tasks from both backends
42    let all_external = task_manager.list_all_external_tasks().await?;
43    let all_internal = task_manager.list_all_internal().await.unwrap_or_default();
44
45    let mut counts: Vec<(Status, usize, usize)> = Vec::new(); // (status, external, internal)
46    let mut total = 0usize;
47    let mut done_tasks = Vec::new();
48    for s in &statuses {
49        let label = s.as_label();
50        let ext_filtered: Vec<_> = all_external
51            .iter()
52            .filter(|t| t.labels.contains(&label.to_string()))
53            .cloned()
54            .collect();
55        let ts = match s {
56            Status::New => TaskStatus::New,
57            Status::Routed => TaskStatus::Routed,
58            Status::InProgress => TaskStatus::InProgress,
59            Status::Done => TaskStatus::Done,
60            Status::Blocked => TaskStatus::Blocked,
61            Status::InReview => TaskStatus::InReview,
62            Status::Approved => TaskStatus::Approved,
63            Status::NeedsReview => TaskStatus::NeedsReview,
64            Status::Waiting => TaskStatus::Waiting,
65        };
66        let int_count = all_internal.iter().filter(|t| t.status == ts).count();
67        total += ext_filtered.len() + int_count;
68        counts.push((*s, ext_filtered.len(), int_count));
69        if *s == Status::Done {
70            done_tasks = ext_filtered;
71        }
72    }
73
74    println!("Tasks ({total} total)");
75
76    for (s, ext, int) in &counts {
77        let count = ext + int;
78        if count > 0 {
79            println!(
80                "  {:<12} {:>3}{}",
81                s.as_label().replace("status:", ""),
82                count,
83                if *int > 0 {
84                    format!(" ({int} internal)")
85                } else {
86                    String::new()
87                }
88            );
89        }
90    }
91
92    let store: Option<Arc<TaskStore>> = cli::init_store().await.ok().map(Arc::new);
93    let repo = config::get_current_repo().unwrap_or_default();
94
95    println!("\nActive Sessions");
96    let tmux = TmuxManager::new();
97    let sessions = tmux.list_sessions().await.unwrap_or_default();
98    for s in &sessions {
99        let agent = store::opt_store_get_task(&store, &repo, &s.task_id)
100            .await
101            .and_then(|t| t.agent)
102            .unwrap_or_default();
103        println!("  {:<25} {:<8} #{}", s.name, agent, s.task_id);
104    }
105
106    println!("\nRecent (last 24h)");
107    let cutoff: DateTime<Utc> = Utc::now() - Duration::hours(24);
108    for r in done_tasks.iter().take(10) {
109        if let Ok(dt) = DateTime::parse_from_rfc3339(&r.updated_at) {
110            let dt_utc = dt.with_timezone(&Utc);
111            if dt_utc >= cutoff {
112                let agent = store::opt_store_get_task(&store, &repo, &r.id.0)
113                    .await
114                    .and_then(|t| t.agent)
115                    .unwrap_or_default();
116                let elapsed = Utc::now() - dt_utc;
117                let mins = elapsed.num_minutes();
118                println!(
119                    "  ✅ #{:<4} {:<30} {:<8} done {:>4}m ago",
120                    r.id.0, r.title, agent, mins
121                );
122            }
123        }
124    }
125
126    Ok(())
127}
128
129/// Dashboard using the global store — works from any directory, across all projects.
130async fn dashboard_from_global_store(project: Option<&str>) -> anyhow::Result<()> {
131    let store = if let Ok(s) = cli::init_store().await {
132        Arc::new(s)
133    } else {
134        println!("Tasks (0 total)\n\nActive Sessions\n\nRecent (last 24h)");
135        return Ok(());
136    };
137
138    // Load all active tasks and recent done tasks from the store.
139    let mut active = store.list_all_active_global().await.unwrap_or_default();
140    let mut done = store
141        .list_all_by_status_global(TaskStatus::Done)
142        .await
143        .unwrap_or_default();
144
145    // Apply optional project filter.
146    if let Some(p) = project {
147        active.retain(|t| matches_project_filter(&t.repo, p));
148        done.retain(|t| matches_project_filter(&t.repo, p));
149    }
150
151    // Build a map from external_id -> agent for quick session lookups.
152    let agent_by_ext_id: HashMap<String, String> = active
153        .iter()
154        .filter_map(|t| {
155            let ext_id = t.external_id.as_deref()?;
156            let agent = t.agent.as_deref()?;
157            Some((ext_id.to_string(), agent.to_string()))
158        })
159        .collect();
160
161    // Count by status.
162    let status_order = [
163        TaskStatus::New,
164        TaskStatus::Routed,
165        TaskStatus::InProgress,
166        TaskStatus::InReview,
167        TaskStatus::Approved,
168        TaskStatus::NeedsReview,
169        TaskStatus::Blocked,
170        TaskStatus::Done,
171    ];
172
173    let done_count = done.len();
174    let total = active.len() + done_count;
175    println!("Tasks ({total} total)");
176
177    for ts in &status_order {
178        let count = if *ts == TaskStatus::Done {
179            done_count
180        } else {
181            active.iter().filter(|t| t.status == *ts).count()
182        };
183        if count > 0 {
184            println!("  {:<12} {:>3}", ts.as_str().replace('_', ""), count);
185        }
186    }
187
188    println!("\nActive Sessions");
189    let tmux = TmuxManager::new();
190    let sessions = tmux.list_sessions().await.unwrap_or_default();
191    for s in &sessions {
192        let agent = agent_by_ext_id
193            .get(&s.task_id)
194            .map(String::as_str)
195            .unwrap_or_default();
196        println!("  {:<25} {:<8} #{}", s.name, agent, s.task_id);
197    }
198
199    println!("\nRecent (last 24h)");
200    let cutoff: DateTime<Utc> = Utc::now() - Duration::hours(24);
201    for r in done.iter().take(10) {
202        if let Ok(dt) = DateTime::parse_from_rfc3339(&r.updated_at) {
203            let dt_utc = dt.with_timezone(&Utc);
204            if dt_utc >= cutoff {
205                let agent = r.agent.as_deref().unwrap_or_default();
206                let elapsed = Utc::now() - dt_utc;
207                let mins = elapsed.num_minutes();
208                let id_str = r.external_id.clone().unwrap_or_else(|| r.id.to_string());
209                println!(
210                    "  ✅ #{:<4} {:<30} {:<8} done {:>4}m ago",
211                    id_str, r.title, agent, mins
212                );
213            }
214        }
215    }
216
217    Ok(())
218}
219
220fn matches_project_filter(repo: &str, project: &str) -> bool {
221    let repo_name = repo.rsplit('/').next().unwrap_or(repo);
222    repo == project || repo_name == project || repo.ends_with(&format!("/{project}"))
223}