Skip to main content

orch/cli/
dashboard.rs

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