1use axum::{
15 extract::{Query, State},
16 http::{HeaderMap, StatusCode, header},
17 response::{Html, IntoResponse, Response},
18};
19use serde::Deserialize;
20use sqlx::PgPool;
21
22use crate::auth::{AuthCtx, resolve_token};
23
24#[derive(Deserialize)]
25pub struct DashboardQuery {
26 token: Option<String>,
27}
28
29fn esc(s: &str) -> String {
30 s.replace('&', "&")
31 .replace('<', "<")
32 .replace('>', ">")
33 .replace('"', """)
34}
35
36fn ago(ts: chrono::DateTime<chrono::Utc>) -> String {
37 let secs = (chrono::Utc::now() - ts).num_seconds().max(0);
38 match secs {
39 0..=59 => format!("{secs}s ago"),
40 60..=3599 => format!("{}m ago", secs / 60),
41 3600..=86399 => format!("{}h ago", secs / 3600),
42 _ => format!("{}d ago", secs / 86400),
43 }
44}
45
46pub async fn render(
47 State(pool): State<PgPool>,
48 Query(q): Query<DashboardQuery>,
49 headers: HeaderMap,
50) -> Response {
51 let raw = q.token.or_else(|| {
53 headers
54 .get(header::AUTHORIZATION)
55 .and_then(|v| v.to_str().ok())
56 .and_then(|v| {
57 v.strip_prefix("Bearer ")
58 .or_else(|| v.strip_prefix("bearer "))
59 })
60 .map(|s| s.trim().to_owned())
61 });
62 let Some(raw) = raw else {
63 return (
64 StatusCode::UNAUTHORIZED,
65 Html("<h1>401</h1><p>Pass your agent token: <code>/dashboard?token=acs_…</code></p>"),
66 )
67 .into_response();
68 };
69 let auth = match resolve_token(&pool, &raw).await {
70 Ok(a) => a,
71 Err(_) => {
72 return (
73 StatusCode::UNAUTHORIZED,
74 Html("<h1>401</h1><p>invalid token</p>"),
75 )
76 .into_response();
77 }
78 };
79
80 match build_page(&pool, &auth).await {
81 Ok(page) => Html(page).into_response(),
82 Err(e) => {
83 tracing::error!(error = %e, "dashboard query failed");
84 (StatusCode::INTERNAL_SERVER_ERROR, Html("<h1>500</h1>")).into_response()
85 }
86 }
87}
88
89async fn build_page(pool: &PgPool, auth: &AuthCtx) -> Result<String, sqlx::Error> {
90 let (agents_online,): (i64,) = sqlx::query_as(
92 r#"SELECT count(*) FROM agents a JOIN agent_presence p ON p.agent_id = a.id
93 WHERE a.team_id = $1 AND p.expires_at > now()"#,
94 )
95 .bind(auth.team_id)
96 .fetch_one(pool)
97 .await?;
98 let (open_tasks, claimed_tasks): (i64, i64) = sqlx::query_as(
99 r#"SELECT count(*) FILTER (WHERE status='open'),
100 count(*) FILTER (WHERE status='claimed')
101 FROM tasks WHERE team_id = $1"#,
102 )
103 .bind(auth.team_id)
104 .fetch_one(pool)
105 .await?;
106 let (messages_24h,): (i64,) = sqlx::query_as(
107 r#"SELECT count(*) FROM messages
108 WHERE team_id = $1 AND channel_id IS NOT NULL
109 AND created_at > now() - interval '24 hours'"#,
110 )
111 .bind(auth.team_id)
112 .fetch_one(pool)
113 .await?;
114
115 let agents: Vec<(
117 String,
118 Option<String>,
119 Option<String>,
120 Option<String>,
121 Option<String>,
122 Option<chrono::DateTime<chrono::Utc>>,
123 bool,
124 )> = sqlx::query_as(
125 r#"SELECT a.name, p.status, p.repo, p.branch, p.activity, p.updated_at,
126 COALESCE(p.expires_at > now(), false)
127 FROM agents a LEFT JOIN agent_presence p ON p.agent_id = a.id
128 WHERE a.team_id = $1 AND a.disabled_at IS NULL
129 ORDER BY COALESCE(p.expires_at > now(), false) DESC, a.name"#,
130 )
131 .bind(auth.team_id)
132 .fetch_all(pool)
133 .await?;
134
135 let tasks: Vec<(
137 String,
138 String,
139 String,
140 Option<String>,
141 Option<String>,
142 bool,
143 chrono::DateTime<chrono::Utc>,
144 )> = sqlx::query_as(
145 r#"SELECT t.key, t.title, t.status, cb.name, t.result,
146 EXISTS (SELECT 1 FROM task_deps td
147 JOIN tasks d ON d.id = td.blocked_by_task_id
148 WHERE td.task_id = t.id
149 AND d.status NOT IN ('done','cancelled')) AS blocked,
150 t.updated_at
151 FROM tasks t LEFT JOIN agents cb ON cb.id = t.claimed_by
152 WHERE t.team_id = $1
153 ORDER BY CASE t.status WHEN 'claimed' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
154 t.updated_at DESC
155 LIMIT 30"#,
156 )
157 .bind(auth.team_id)
158 .fetch_all(pool)
159 .await?;
160
161 let messages: Vec<(i64, String, String, String, chrono::DateTime<chrono::Utc>)> =
163 sqlx::query_as(
164 r#"SELECT m.id, ch.name, s.name, left(m.body, 240), m.created_at
165 FROM messages m
166 JOIN channels ch ON ch.id = m.channel_id
167 JOIN agents s ON s.id = m.sender_agent_id
168 WHERE m.team_id = $1
169 ORDER BY m.id DESC LIMIT 20"#,
170 )
171 .bind(auth.team_id)
172 .fetch_all(pool)
173 .await?;
174
175 let locks: Vec<(
176 String,
177 String,
178 Option<String>,
179 chrono::DateTime<chrono::Utc>,
180 )> = sqlx::query_as(
181 r#"SELECT l.name, a.name, l.purpose, l.expires_at
182 FROM locks l JOIN agents a ON a.id = l.holder_agent_id
183 WHERE l.team_id = $1 AND l.expires_at > now() ORDER BY l.name"#,
184 )
185 .bind(auth.team_id)
186 .fetch_all(pool)
187 .await?;
188
189 let notes: Vec<(
190 String,
191 String,
192 Option<String>,
193 chrono::DateTime<chrono::Utc>,
194 )> = sqlx::query_as(
195 r#"SELECT n.scope, n.key, a.name, n.updated_at
196 FROM notes n LEFT JOIN agents a ON a.id = n.updated_by
197 WHERE n.team_id = $1 ORDER BY n.updated_at DESC LIMIT 15"#,
198 )
199 .bind(auth.team_id)
200 .fetch_all(pool)
201 .await?;
202
203 let mut agent_rows = String::new();
205 for (name, status, repo, branch, activity, updated, online) in &agents {
206 let (dot, label) = if *online {
208 match status.as_deref() {
209 Some("blocked") => (
210 "<span class=\"st st-serious\">●</span> ⛔ blocked",
211 "blocked",
212 ),
213 Some("busy") => ("<span class=\"st st-warning\">●</span> ⚙ busy", "busy"),
214 Some("idle") => ("<span class=\"st st-muted\">●</span> ◌ idle", "idle"),
215 _ => ("<span class=\"st st-good\">●</span> ✓ active", "active"),
216 }
217 } else {
218 ("<span class=\"st st-muted\">●</span> ○ offline", "offline")
219 };
220 let _ = label;
221 let place = match (repo, branch) {
222 (Some(r), Some(b)) => format!("{}@{}", esc(r), esc(b)),
223 (Some(r), None) => esc(r),
224 _ => String::new(),
225 };
226 agent_rows.push_str(&format!(
227 "<tr><td><strong>{}</strong></td><td>{}</td><td>{}</td><td>{}</td><td class=\"muted\">{}</td></tr>",
228 esc(name),
229 dot,
230 place,
231 esc(activity.as_deref().unwrap_or("")),
232 updated.map(ago).unwrap_or_default(),
233 ));
234 }
235
236 let mut task_rows = String::new();
237 for (key, title, status, holder, result, blocked, updated) in &tasks {
238 let badge = match status.as_str() {
239 "done" => "<span class=\"st st-good\">●</span> ✓ done".to_string(),
240 "claimed" => format!(
241 "<span class=\"st st-warning\">●</span> ⚙ claimed by {}",
242 esc(holder.as_deref().unwrap_or("?"))
243 ),
244 "cancelled" => "<span class=\"st st-muted\">●</span> ✕ cancelled".to_string(),
245 _ if *blocked => "<span class=\"st st-serious\">●</span> ⛔ blocked".to_string(),
246 _ => "<span class=\"st st-muted\">●</span> ◌ open".to_string(),
247 };
248 let detail = result.as_deref().unwrap_or("");
249 task_rows.push_str(&format!(
250 "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td class=\"muted\">{}</td></tr>",
251 esc(key),
252 esc(title),
253 badge,
254 esc(detail),
255 ago(*updated),
256 ));
257 }
258
259 let mut msg_rows = String::new();
260 for (_, channel, sender, body, at) in messages.iter().rev() {
261 msg_rows.push_str(&format!(
262 "<tr><td class=\"muted\">{}</td><td>#{}</td><td><strong>{}</strong></td><td>{}</td></tr>",
263 ago(*at),
264 esc(channel),
265 esc(sender),
266 esc(body),
267 ));
268 }
269
270 let mut lock_rows = String::new();
271 for (name, holder, purpose, expires) in &locks {
272 lock_rows.push_str(&format!(
273 "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td class=\"muted\">expires {}</td></tr>",
274 esc(name),
275 esc(holder),
276 esc(purpose.as_deref().unwrap_or("")),
277 ago(*expires).replace(" ago", ""),
278 ));
279 }
280 if locks.is_empty() {
281 lock_rows.push_str("<tr><td colspan=4 class=\"muted\">no locks held</td></tr>");
282 }
283
284 let mut note_rows = String::new();
285 for (scope, key, by, at) in ¬es {
286 note_rows.push_str(&format!(
287 "<tr><td class=\"muted\">{}</td><td><code>{}/{}</code></td><td>{}</td></tr>",
288 ago(*at),
289 esc(scope),
290 esc(key),
291 esc(by.as_deref().unwrap_or("")),
292 ));
293 }
294
295 Ok(format!(
296 r##"<!doctype html>
297<html lang="en">
298<head>
299<meta charset="utf-8">
300<meta name="viewport" content="width=device-width, initial-scale=1">
301<meta http-equiv="refresh" content="15">
302<title>ai-crew-sync · {team}</title>
303<style>
304:root {{
305 --surface: #fcfcfb; --card: #ffffff; --border: #e4e3df;
306 --ink: #0b0b0b; --ink-2: #52514e; --ink-3: #8a8984;
307 --good: #0ca30c; --warning: #fab219; --serious: #ec835a; --critical: #d03b3b;
308}}
309@media (prefers-color-scheme: dark) {{
310 :root {{
311 --surface: #1a1a19; --card: #232322; --border: #3a3936;
312 --ink: #ffffff; --ink-2: #c3c2b7; --ink-3: #8a8984;
313 }}
314}}
315* {{ box-sizing: border-box; }}
316body {{
317 margin: 0; padding: 24px; background: var(--surface); color: var(--ink);
318 font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
319}}
320h1 {{ font-size: 18px; margin: 0 0 4px; }}
321h2 {{ font-size: 13px; margin: 28px 0 8px; color: var(--ink-2);
322 text-transform: uppercase; letter-spacing: .06em; }}
323.sub {{ color: var(--ink-3); margin-bottom: 20px; }}
324.tiles {{ display: flex; gap: 12px; flex-wrap: wrap; }}
325.tile {{
326 background: var(--card); border: 1px solid var(--border); border-radius: 10px;
327 padding: 14px 18px; min-width: 130px;
328}}
329.tile .n {{ font-size: 26px; font-weight: 650; }}
330.tile .l {{ color: var(--ink-2); font-size: 12px; }}
331table {{ width: 100%; border-collapse: collapse; background: var(--card);
332 border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }}
333td {{ padding: 7px 12px; border-top: 1px solid var(--border); vertical-align: top; }}
334tr:first-child td {{ border-top: none; }}
335code {{ background: transparent; color: inherit; }}
336.muted {{ color: var(--ink-3); white-space: nowrap; }}
337.st {{ font-size: 10px; vertical-align: 1px; }}
338.st-good {{ color: var(--good); }}
339.st-warning {{ color: var(--warning); }}
340.st-serious {{ color: var(--serious); }}
341.st-muted {{ color: var(--ink-3); }}
342</style>
343</head>
344<body>
345<h1>ai-crew-sync · {team}</h1>
346<div class="sub">viewed as {viewer} · refreshes every 15s · direct messages never shown</div>
347
348<div class="tiles">
349 <div class="tile"><div class="n">{agents_online}</div><div class="l">agents online</div></div>
350 <div class="tile"><div class="n">{claimed_tasks}</div><div class="l">tasks in progress</div></div>
351 <div class="tile"><div class="n">{open_tasks}</div><div class="l">tasks open</div></div>
352 <div class="tile"><div class="n">{messages_24h}</div><div class="l">channel msgs · 24h</div></div>
353</div>
354
355<h2>Agents</h2>
356<table>{agent_rows}</table>
357
358<h2>Tasks</h2>
359<table>{task_rows}</table>
360
361<h2>Locks</h2>
362<table>{lock_rows}</table>
363
364<h2>Latest channel messages</h2>
365<table>{msg_rows}</table>
366
367<h2>Recently updated notes</h2>
368<table>{note_rows}</table>
369</body>
370</html>"##,
371 team = esc(&auth.team_slug),
372 viewer = esc(&auth.agent_name),
373 ))
374}