1pub 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
29fn esc(s: &str) -> String {
34 s.replace('&', "&")
35 .replace('<', "<")
36 .replace('>', ">")
37 .replace('"', """)
38 .replace('\'', "'")
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
51const COOKIE: &str = "acs_dashboard";
53const GRANT_TTL_SECS: i64 = 3600;
55
56#[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
91fn 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 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
128pub async fn login(
130 State(state): State<DashboardState>,
131 headers: HeaderMap,
132 axum::extract::Form(form): axum::extract::Form<LoginForm>,
133) -> Response {
134 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 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 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 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
221async 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 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 ¬es {
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}