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    };
211
212    match build_page(&state.pool, &auth).await {
213        Ok(page) => harden(Html(page).into_response()),
214        Err(e) => {
215            tracing::error!(error = %e, "dashboard query failed");
216            harden((StatusCode::INTERNAL_SERVER_ERROR, Html("<h1>500</h1>")).into_response())
217        }
218    }
219}
220
221/// Render the page from a snapshot. All the SQL lives in [`data`]; this
222/// function only turns rows into HTML.
223async fn build_page(pool: &PgPool, auth: &AuthCtx) -> Result<String, sqlx::Error> {
224    let data::Snapshot {
225        team,
226        totals,
227        agents,
228        tasks,
229        messages,
230        locks,
231        notes,
232    } = data::load(pool, auth.team_id).await?;
233    let agents_online = totals.agents_online;
234    let open_tasks = totals.open_tasks;
235    let claimed_tasks = totals.claimed_tasks;
236    let messages_24h = totals.messages_24h;
237
238    let mut agent_rows = String::new();
239    for a in &agents {
240        // Status: icon + label, never color alone.
241        let (dot, label) = if a.online {
242            match a.status.as_deref() {
243                Some("blocked") => (
244                    "<span class=\"st st-serious\">●</span> ⛔ blocked",
245                    "blocked",
246                ),
247                Some("busy") => ("<span class=\"st st-warning\">●</span> ⚙ busy", "busy"),
248                Some("idle") => ("<span class=\"st st-muted\">●</span> ◌ idle", "idle"),
249                _ => ("<span class=\"st st-good\">●</span> ✓ active", "active"),
250            }
251        } else {
252            ("<span class=\"st st-muted\">●</span> ○ offline", "offline")
253        };
254        let _ = label;
255        let place = match (&a.repo, &a.branch) {
256            (Some(r), Some(b)) => format!("{}@{}", esc(r), esc(b)),
257            (Some(r), None) => esc(r),
258            _ => String::new(),
259        };
260        agent_rows.push_str(&format!(
261            "<tr><td><strong>{}</strong></td><td>{}</td><td>{}</td><td>{}</td><td class=\"muted\">{}</td></tr>",
262            esc(&a.name),
263            dot,
264            place,
265            esc(a.activity.as_deref().unwrap_or("")),
266            a.updated_at.map(ago).unwrap_or_default(),
267        ));
268    }
269
270    let mut task_rows = String::new();
271    for t in &tasks {
272        let badge = match t.status.as_str() {
273            "done" => "<span class=\"st st-good\">●</span> ✓ done".to_string(),
274            "claimed" => format!(
275                "<span class=\"st st-warning\">●</span> ⚙ claimed by {}",
276                esc(t.claimed_by.as_deref().unwrap_or("?"))
277            ),
278            "cancelled" => "<span class=\"st st-muted\">●</span> ✕ cancelled".to_string(),
279            _ if t.blocked => "<span class=\"st st-serious\">●</span> ⛔ blocked".to_string(),
280            _ => "<span class=\"st st-muted\">●</span> ◌ open".to_string(),
281        };
282        let detail = t.result.as_deref().unwrap_or("");
283        task_rows.push_str(&format!(
284            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td class=\"muted\">{}</td></tr>",
285            esc(&t.key),
286            esc(&t.title),
287            badge,
288            esc(detail),
289            ago(t.updated_at),
290        ));
291    }
292
293    let mut msg_rows = String::new();
294    for m in messages.iter().rev() {
295        msg_rows.push_str(&format!(
296            "<tr><td class=\"muted\">{}</td><td>#{}</td><td><strong>{}</strong></td><td>{}</td></tr>",
297            ago(m.created_at),
298            esc(&m.channel),
299            esc(&m.sender),
300            esc(&m.body),
301        ));
302    }
303
304    let mut lock_rows = String::new();
305    for l in &locks {
306        lock_rows.push_str(&format!(
307            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td class=\"muted\">expires {}</td></tr>",
308            esc(&l.name),
309            esc(&l.holder),
310            esc(l.purpose.as_deref().unwrap_or("")),
311            ago(l.expires_at).replace(" ago", ""),
312        ));
313    }
314    if locks.is_empty() {
315        lock_rows.push_str("<tr><td colspan=4 class=\"muted\">no locks held</td></tr>");
316    }
317
318    let mut note_rows = String::new();
319    for n in &notes {
320        note_rows.push_str(&format!(
321            "<tr><td class=\"muted\">{}</td><td><code>{}/{}</code></td><td>{}</td></tr>",
322            ago(n.updated_at),
323            esc(&n.scope),
324            esc(&n.key),
325            esc(n.updated_by.as_deref().unwrap_or("")),
326        ));
327    }
328
329    Ok(format!(
330        r##"<!doctype html>
331<html lang="en">
332<head>
333<meta charset="utf-8">
334<meta name="viewport" content="width=device-width, initial-scale=1">
335<meta http-equiv="refresh" content="15">
336<title>ai-crew-sync · {team}</title>
337<style>
338:root {{
339  --surface: #fcfcfb; --card: #ffffff; --border: #e4e3df;
340  --ink: #0b0b0b; --ink-2: #52514e; --ink-3: #8a8984;
341  --good: #0ca30c; --warning: #fab219; --serious: #ec835a; --critical: #d03b3b;
342}}
343@media (prefers-color-scheme: dark) {{
344  :root {{
345    --surface: #1a1a19; --card: #232322; --border: #3a3936;
346    --ink: #ffffff; --ink-2: #c3c2b7; --ink-3: #8a8984;
347  }}
348}}
349* {{ box-sizing: border-box; }}
350body {{
351  margin: 0; padding: 24px; background: var(--surface); color: var(--ink);
352  font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
353}}
354h1 {{ font-size: 18px; margin: 0 0 4px; }}
355h2 {{ font-size: 13px; margin: 28px 0 8px; color: var(--ink-2);
356     text-transform: uppercase; letter-spacing: .06em; }}
357.sub {{ color: var(--ink-3); margin-bottom: 20px; }}
358.tiles {{ display: flex; gap: 12px; flex-wrap: wrap; }}
359.tile {{
360  background: var(--card); border: 1px solid var(--border); border-radius: 10px;
361  padding: 14px 18px; min-width: 130px;
362}}
363.tile .n {{ font-size: 26px; font-weight: 650; }}
364.tile .l {{ color: var(--ink-2); font-size: 12px; }}
365table {{ width: 100%; border-collapse: collapse; background: var(--card);
366        border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }}
367td {{ padding: 7px 12px; border-top: 1px solid var(--border); vertical-align: top; }}
368tr:first-child td {{ border-top: none; }}
369code {{ background: transparent; color: inherit; }}
370.muted {{ color: var(--ink-3); white-space: nowrap; }}
371.st {{ font-size: 10px; vertical-align: 1px; }}
372.st-good {{ color: var(--good); }}
373.st-warning {{ color: var(--warning); }}
374.st-serious {{ color: var(--serious); }}
375.st-muted {{ color: var(--ink-3); }}
376</style>
377</head>
378<body>
379<h1>ai-crew-sync · {team}</h1>
380<div class="sub">viewed as {viewer} · refreshes every 15s · direct messages never shown</div>
381
382<div class="tiles">
383  <div class="tile"><div class="n">{agents_online}</div><div class="l">agents online</div></div>
384  <div class="tile"><div class="n">{claimed_tasks}</div><div class="l">tasks in progress</div></div>
385  <div class="tile"><div class="n">{open_tasks}</div><div class="l">tasks open</div></div>
386  <div class="tile"><div class="n">{messages_24h}</div><div class="l">channel msgs · 24h</div></div>
387</div>
388
389<h2>Agents</h2>
390<table>{agent_rows}</table>
391
392<h2>Tasks</h2>
393<table>{task_rows}</table>
394
395<h2>Locks</h2>
396<table>{lock_rows}</table>
397
398<h2>Latest channel messages</h2>
399<table>{msg_rows}</table>
400
401<h2>Recently updated notes</h2>
402<table>{note_rows}</table>
403</body>
404</html>"##,
405        team = esc(&team),
406        viewer = esc(&auth.agent_name),
407    ))
408}