Skip to main content

ai_crew_sync/dashboard/
mod.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: an agent token is presented ONCE — as a form POST to
5//! `/dashboard/login` or an `Authorization: Bearer` header — and exchanged for
6//! a short-lived, read-only, HttpOnly cookie ([`grant`]). Tokens are never
7//! accepted in the query string: a full-privilege credential in a URL leaks
8//! into browser history, copied links, referrer headers and proxy logs.
9//!
10//! Design notes (from the dataviz method): this page is stat tiles + tables,
11//! not charts, so no categorical palette is involved. Status colors are the
12//! reserved status palette and never appear without an icon + text label;
13//! values wear text tokens, never status hues. Light and dark surfaces are the
14//! validated reference pair.
15
16pub mod data;
17pub mod grant;
18
19use axum::{
20    extract::State,
21    http::{HeaderMap, StatusCode, header},
22    response::{Html, IntoResponse, Response},
23};
24use serde::Deserialize;
25use sqlx::PgPool;
26
27use crate::auth::{AuthCtx, resolve_token};
28
29/// Escape user content for HTML. Covers the single quote too: every current
30/// interpolation sits in an element body or a double-quoted attribute, but one
31/// future single-quoted attribute would otherwise turn a message body or an
32/// agent name into markup.
33fn esc(s: &str) -> String {
34    s.replace('&', "&")
35        .replace('<', "&lt;")
36        .replace('>', "&gt;")
37        .replace('"', "&quot;")
38        .replace('\'', "&#39;")
39}
40
41fn ago(ts: chrono::DateTime<chrono::Utc>) -> String {
42    let secs = (chrono::Utc::now() - ts).num_seconds().max(0);
43    match secs {
44        0..=59 => format!("{secs}s ago"),
45        60..=3599 => format!("{}m ago", secs / 60),
46        3600..=86399 => format!("{}h ago", secs / 3600),
47        _ => format!("{}d ago", secs / 86400),
48    }
49}
50
51/// Name of the cookie carrying the read-only grant.
52const COOKIE: &str = "acs_dashboard";
53/// Grants are cheap to re-mint, so keep the window short.
54const GRANT_TTL_SECS: i64 = 3600;
55
56/// Signing key for dashboard grants, plus the page's own state.
57#[derive(Clone)]
58pub struct DashboardState {
59    pub pool: PgPool,
60    pub secret: std::sync::Arc<Vec<u8>>,
61}
62
63#[derive(Deserialize)]
64pub struct LoginForm {
65    token: Option<String>,
66}
67
68fn bearer(headers: &HeaderMap) -> Option<String> {
69    headers
70        .get(header::AUTHORIZATION)
71        .and_then(|v| v.to_str().ok())
72        .and_then(|v| {
73            v.strip_prefix("Bearer ")
74                .or_else(|| v.strip_prefix("bearer "))
75        })
76        .map(|s| s.trim().to_owned())
77        .filter(|s| !s.is_empty())
78}
79
80fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
81    headers
82        .get(header::COOKIE)?
83        .to_str()
84        .ok()?
85        .split(';')
86        .filter_map(|pair| pair.trim().split_once('='))
87        .find(|(k, _)| *k == name)
88        .map(|(_, v)| v.to_owned())
89}
90
91/// Never cache a page listing team activity, and never leak the URL onward.
92fn harden(mut resp: Response) -> Response {
93    let h = resp.headers_mut();
94    h.insert(
95        header::CACHE_CONTROL,
96        header::HeaderValue::from_static("no-store"),
97    );
98    h.insert(
99        header::REFERRER_POLICY,
100        header::HeaderValue::from_static("no-referrer"),
101    );
102    resp
103}
104
105fn login_page(status: StatusCode, message: &str) -> Response {
106    // A form POST keeps the token out of the URL; the browser sends it in the
107    // body, and it never appears in history or a referrer.
108    let body = format!(
109        r#"<!doctype html><meta charset="utf-8"><title>ai-crew-sync</title>
110<style>body{{font:15px/1.5 system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem}}
111input{{width:100%;padding:.6rem;font:inherit;font-family:ui-monospace,monospace}}
112button{{margin-top:.75rem;padding:.6rem 1.2rem;font:inherit}}
113p{{color:#666}}</style>
114<h1>ai-crew-sync</h1>
115<p>{}</p>
116<form method="post" action="/dashboard/login">
117  <label>Agent token<br><input name="token" type="password" autocomplete="off"
118         placeholder="acs_…" autofocus></label>
119  <button type="submit">Open dashboard</button>
120</form>
121<p>Read-only. The token is exchanged for a session cookie that expires in an
122hour and cannot call MCP tools.</p>"#,
123        esc(message)
124    );
125    harden((status, Html(body)).into_response())
126}
127
128/// Exchange an agent token for a read-only grant cookie.
129pub async fn login(
130    State(state): State<DashboardState>,
131    headers: HeaderMap,
132    axum::extract::Form(form): axum::extract::Form<LoginForm>,
133) -> Response {
134    // Scripts and `curl` do not need this exchange at all — they pass the
135    // bearer header straight to GET /dashboard.
136    let raw = form.token.filter(|t| !t.trim().is_empty());
137    let Some(raw) = raw else {
138        return login_page(
139            StatusCode::UNAUTHORIZED,
140            "Paste your agent token to continue.",
141        );
142    };
143
144    let auth = match resolve_token(&state.pool, &raw).await {
145        Ok(a) => a,
146        Err(_) => {
147            return login_page(
148                StatusCode::UNAUTHORIZED,
149                "That token is not valid, or it has been revoked.",
150            );
151        }
152    };
153
154    let grant = grant::issue(
155        &state.secret,
156        auth.team_id,
157        chrono::Utc::now().timestamp(),
158        GRANT_TTL_SECS,
159    );
160    // Secure is set when the request reached us over TLS, directly or through
161    // a proxy that says so — marking it unconditionally would break plain-HTTP
162    // local use, and omitting it always would be wrong in production.
163    let secure = headers
164        .get("x-forwarded-proto")
165        .and_then(|v| v.to_str().ok())
166        .map(|v| v.eq_ignore_ascii_case("https"))
167        .unwrap_or(false);
168    let cookie = format!(
169        "{COOKIE}={grant}; Path=/dashboard; HttpOnly; SameSite=Strict; Max-Age={GRANT_TTL_SECS}{}",
170        if secure { "; Secure" } else { "" }
171    );
172
173    let mut resp = axum::response::Redirect::to("/dashboard").into_response();
174    if let Ok(value) = header::HeaderValue::from_str(&cookie) {
175        resp.headers_mut().insert(header::SET_COOKIE, value);
176    }
177    harden(resp)
178}
179
180pub async fn render(State(state): State<DashboardState>, headers: HeaderMap) -> Response {
181    // A grant cookie, or a bearer header for `curl` and scripts. Never a
182    // query parameter.
183    let team_id = match cookie_value(&headers, COOKIE)
184        .and_then(|g| grant::verify(&state.secret, &g, chrono::Utc::now().timestamp()))
185    {
186        Some(team) => team,
187        None => match bearer(&headers) {
188            Some(raw) => match resolve_token(&state.pool, &raw).await {
189                Ok(a) => a.team_id,
190                Err(_) => {
191                    return login_page(StatusCode::UNAUTHORIZED, "That token is not valid.");
192                }
193            },
194            None => {
195                return login_page(
196                    StatusCode::UNAUTHORIZED,
197                    "Sign in with an agent token to view this team's dashboard.",
198                );
199            }
200        },
201    };
202
203    // The grant proves team membership and nothing more; the page is built
204    // from the team alone, never from an agent identity.
205    let auth = AuthCtx {
206        agent_id: uuid::Uuid::nil(),
207        agent_name: String::new(),
208        team_id,
209        team_slug: String::new(),
210        // The dashboard is a team-wide view, not a working context.
211        session: String::new(),
212    };
213
214    match build_page(&state.pool, &auth).await {
215        Ok(page) => harden(Html(page).into_response()),
216        Err(e) => {
217            tracing::error!(error = %e, "dashboard query failed");
218            harden((StatusCode::INTERNAL_SERVER_ERROR, Html("<h1>500</h1>")).into_response())
219        }
220    }
221}
222
223/// Render the page from a snapshot. All the SQL lives in [`data`]; this
224/// function only turns rows into HTML.
225async fn build_page(pool: &PgPool, auth: &AuthCtx) -> Result<String, sqlx::Error> {
226    let data::Snapshot {
227        team,
228        totals,
229        agents,
230        tasks,
231        messages,
232        locks,
233        notes,
234    } = data::load(pool, auth.team_id).await?;
235    let agents_online = totals.agents_online;
236    let open_tasks = totals.open_tasks;
237    let claimed_tasks = totals.claimed_tasks;
238    let messages_24h = totals.messages_24h;
239
240    let mut agent_rows = String::new();
241    for a in &agents {
242        // Status: icon + label, never color alone.
243        let (dot, label) = if a.online {
244            match a.status.as_deref() {
245                Some("blocked") => (
246                    "<span class=\"st st-serious\">●</span> ⛔ blocked",
247                    "blocked",
248                ),
249                Some("busy") => ("<span class=\"st st-warning\">●</span> ⚙ busy", "busy"),
250                Some("idle") => ("<span class=\"st st-muted\">●</span> ◌ idle", "idle"),
251                _ => ("<span class=\"st st-good\">●</span> ✓ active", "active"),
252            }
253        } else {
254            ("<span class=\"st st-muted\">●</span> ○ offline", "offline")
255        };
256        let _ = label;
257        let place = match (&a.repo, &a.branch) {
258            (Some(r), Some(b)) => format!("{}@{}", esc(r), esc(b)),
259            (Some(r), None) => esc(r),
260            _ => String::new(),
261        };
262        // The session qualifies the name rather than taking its own column:
263        // most teams have one context per person, and an empty column on every
264        // row would cost more than it explains.
265        let who = if a.session.is_empty() {
266            format!("<strong>{}</strong>", esc(&a.name))
267        } else {
268            format!(
269                "<strong>{}</strong> <span class=\"muted\">/{}</span>",
270                esc(&a.name),
271                esc(&a.session)
272            )
273        };
274        agent_rows.push_str(&format!(
275            "<tr><td>{who}</td><td>{}</td><td>{}</td><td>{}</td><td class=\"muted\">{}</td></tr>",
276            dot,
277            place,
278            esc(a.activity.as_deref().unwrap_or("")),
279            a.updated_at.map(ago).unwrap_or_default(),
280        ));
281    }
282
283    let mut task_rows = String::new();
284    for t in &tasks {
285        let badge = match t.status.as_str() {
286            "done" => "<span class=\"st st-good\">●</span> ✓ done".to_string(),
287            "claimed" => format!(
288                "<span class=\"st st-warning\">●</span> ⚙ claimed by {}",
289                esc(t.claimed_by.as_deref().unwrap_or("?"))
290            ),
291            "cancelled" => "<span class=\"st st-muted\">●</span> ✕ cancelled".to_string(),
292            _ if t.blocked => "<span class=\"st st-serious\">●</span> ⛔ blocked".to_string(),
293            _ => "<span class=\"st st-muted\">●</span> ◌ open".to_string(),
294        };
295        let detail = t.result.as_deref().unwrap_or("");
296        task_rows.push_str(&format!(
297            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td class=\"muted\">{}</td></tr>",
298            esc(&t.key),
299            esc(&t.title),
300            badge,
301            esc(detail),
302            ago(t.updated_at),
303        ));
304    }
305
306    let mut msg_rows = String::new();
307    for m in messages.iter().rev() {
308        // Marked with an icon and a word, never colour alone: an announcement
309        // is what the team was interrupted for, and the panel should say so.
310        let channel = if m.announce {
311            format!(
312                "#{} <span class=\"st st-warning\">●</span> announced",
313                esc(&m.channel)
314            )
315        } else {
316            format!("#{}", esc(&m.channel))
317        };
318        msg_rows.push_str(&format!(
319            "<tr><td class=\"muted\">{}</td><td>{channel}</td><td><strong>{}</strong></td><td>{}</td></tr>",
320            ago(m.created_at),
321            esc(&m.sender),
322            esc(&m.body),
323        ));
324    }
325
326    let mut lock_rows = String::new();
327    for l in &locks {
328        lock_rows.push_str(&format!(
329            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td class=\"muted\">expires {}</td></tr>",
330            esc(&l.name),
331            esc(&l.holder),
332            esc(l.purpose.as_deref().unwrap_or("")),
333            ago(l.expires_at).replace(" ago", ""),
334        ));
335    }
336    if locks.is_empty() {
337        lock_rows.push_str("<tr><td colspan=4 class=\"muted\">no locks held</td></tr>");
338    }
339
340    let mut note_rows = String::new();
341    for n in &notes {
342        note_rows.push_str(&format!(
343            "<tr><td class=\"muted\">{}</td><td><code>{}/{}</code></td><td>{}</td></tr>",
344            ago(n.updated_at),
345            esc(&n.scope),
346            esc(&n.key),
347            esc(n.updated_by.as_deref().unwrap_or("")),
348        ));
349    }
350
351    Ok(format!(
352        r##"<!doctype html>
353<html lang="en">
354<head>
355<meta charset="utf-8">
356<meta name="viewport" content="width=device-width, initial-scale=1">
357<meta http-equiv="refresh" content="15">
358<title>ai-crew-sync · {team}</title>
359<style>
360:root {{
361  --surface: #fcfcfb; --card: #ffffff; --border: #e4e3df;
362  --ink: #0b0b0b; --ink-2: #52514e; --ink-3: #8a8984;
363  --good: #0ca30c; --warning: #fab219; --serious: #ec835a; --critical: #d03b3b;
364}}
365@media (prefers-color-scheme: dark) {{
366  :root {{
367    --surface: #1a1a19; --card: #232322; --border: #3a3936;
368    --ink: #ffffff; --ink-2: #c3c2b7; --ink-3: #8a8984;
369  }}
370}}
371* {{ box-sizing: border-box; }}
372body {{
373  margin: 0; padding: 24px; background: var(--surface); color: var(--ink);
374  font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
375}}
376h1 {{ font-size: 18px; margin: 0 0 4px; }}
377h2 {{ font-size: 13px; margin: 28px 0 8px; color: var(--ink-2);
378     text-transform: uppercase; letter-spacing: .06em; }}
379.sub {{ color: var(--ink-3); margin-bottom: 20px; }}
380.tiles {{ display: flex; gap: 12px; flex-wrap: wrap; }}
381.tile {{
382  background: var(--card); border: 1px solid var(--border); border-radius: 10px;
383  padding: 14px 18px; min-width: 130px;
384}}
385.tile .n {{ font-size: 26px; font-weight: 650; }}
386.tile .l {{ color: var(--ink-2); font-size: 12px; }}
387table {{ width: 100%; border-collapse: collapse; background: var(--card);
388        border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }}
389td {{ padding: 7px 12px; border-top: 1px solid var(--border); vertical-align: top; }}
390tr:first-child td {{ border-top: none; }}
391code {{ background: transparent; color: inherit; }}
392.muted {{ color: var(--ink-3); white-space: nowrap; }}
393.st {{ font-size: 10px; vertical-align: 1px; }}
394.st-good {{ color: var(--good); }}
395.st-warning {{ color: var(--warning); }}
396.st-serious {{ color: var(--serious); }}
397.st-muted {{ color: var(--ink-3); }}
398</style>
399</head>
400<body>
401<h1>ai-crew-sync · {team}</h1>
402<div class="sub">viewed as {viewer} · refreshes every 15s · direct messages never shown</div>
403
404<div class="tiles">
405  <div class="tile"><div class="n">{agents_online}</div><div class="l">agents online</div></div>
406  <div class="tile"><div class="n">{claimed_tasks}</div><div class="l">tasks in progress</div></div>
407  <div class="tile"><div class="n">{open_tasks}</div><div class="l">tasks open</div></div>
408  <div class="tile"><div class="n">{messages_24h}</div><div class="l">channel msgs · 24h</div></div>
409</div>
410
411<h2>Agents</h2>
412<table>{agent_rows}</table>
413
414<h2>Tasks</h2>
415<table>{task_rows}</table>
416
417<h2>Locks</h2>
418<table>{lock_rows}</table>
419
420<h2>Latest channel messages</h2>
421<table>{msg_rows}</table>
422
423<h2>Recently updated notes</h2>
424<table>{note_rows}</table>
425</body>
426</html>"##,
427        team = esc(&team),
428        viewer = esc(&auth.agent_name),
429    ))
430}