Skip to main content

ai_crew_sync/
dashboard.rs

1//! Read-only HTML dashboard for humans: who is online, what is claimed, what
2//! the channels are saying. Served by the bus itself at `/dashboard`.
3//!
4//! Auth: any agent token of the team, passed as `?token=acs_...` or as an
5//! `Authorization: Bearer` header. Query-param tokens can end up in proxy
6//! logs — acceptable for an internal tool, documented in the README.
7//!
8//! Design notes (from the dataviz method): this page is stat tiles + tables,
9//! not charts, so no categorical palette is involved. Status colors are the
10//! reserved status palette and never appear without an icon + text label;
11//! values wear text tokens, never status hues. Light and dark surfaces are the
12//! validated reference pair.
13
14use 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('&', "&amp;")
31        .replace('<', "&lt;")
32        .replace('>', "&gt;")
33        .replace('"', "&quot;")
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    // Token from query param or bearer header.
52    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    // Stat-tile numbers.
91    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    // Presence table.
116    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    // Tasks: claimed and open first, then latest done.
136    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    // Channel tail (team-visible only; DMs never appear here).
162    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    // ---- render ----
204    let mut agent_rows = String::new();
205    for (name, status, repo, branch, activity, updated, online) in &agents {
206        // Status: icon + label, never color alone.
207        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 &notes {
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}