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 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
223async 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 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 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 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 ¬es {
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}