//! Radar (spec §17): the human's pages, rendered with askama, refreshed //! from the event stream, working without JavaScript. Linked by `spectra` //! and, later, mounted by SpectraHub. //! //! The owner signs in with a one-time link their CLI signed (`spectra //! open`); everything they do here is recorded with that signed request as //! its proof (spec §8.3). Every unsafe request carries the session's CSRF //! token and a same-origin `Origin`. use askama::Template; use axum::body::Bytes; use axum::extract::connect_info::ConnectInfo; use axum::extract::{Form, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::Router; use futures::StreamExt; use serde::Deserialize; use spectra_core::conversation::{self as conv, Author, Post, Signer}; use spectra_core::event::{Auth, Method}; use spectra_core::ids::{format_time, now_ms}; use spectra_core::principals::{Principal, Principals}; use spectra_mcp::http::{AppState, PeerInfo}; use spectra_mcp::session::{self, Session}; use std::time::Duration; const MARK: &str = include_str!("../assets/spectra.svg"); const STYLE: &str = include_str!("../assets/style.css"); const SCRIPT: &str = include_str!("../assets/radar.js"); const COOKIE: &str = "spectra_session"; #[derive(Clone)] pub struct RadarState { pub app: AppState, } impl RadarState { fn sessions(&self) -> &session::Sessions { &self.app.sessions } } /// Radar's routes. pub fn router(app: AppState) -> Router { let state = RadarState { app }; Router::new() .route("/", get(live)) .route("/channels", get(channels)) .route("/c/{name}", get(channel).post(post_channel)) .route("/c/{name}/t/{id}", get(thread).post(post_thread)) .route("/briefs", get(briefs).post(save_brief)) .route("/constellation", get(constellation)) .route("/radar/constellation.json", get(constellation_json)) .route("/radar/constellation.js", get(|| async { asset("text/javascript; charset=utf-8", SKY_SCRIPT) })) .route("/log", get(log)) .route("/log/verify", post(verify)) .route("/radar/link", post(link)) .route("/signin", get(signin)) .route("/signout", post(signout)) .route("/radar/events", get(events)) .route("/radar/style.css", get(|| async { asset("text/css; charset=utf-8", STYLE) })) .route("/radar/radar.js", get(|| async { asset("text/javascript; charset=utf-8", SCRIPT) })) .route("/radar/spectra.svg", get(|| async { asset("image/svg+xml", MARK) })) .layer(axum::middleware::map_response(security_headers)) .with_state(state) } fn asset(kind: &'static str, body: &'static str) -> Response { ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "max-age=300")], body).into_response() } /// CSP and friends on every response (spec §21.2). async fn security_headers(mut r: Response) -> Response { let h = r.headers_mut(); h.insert("content-security-policy", HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'; base-uri 'none'")); h.insert("x-content-type-options", HeaderValue::from_static("nosniff")); // `same-origin`: a form post still carries our Origin (no-referrer makes it `null`). h.insert("referrer-policy", HeaderValue::from_static("same-origin")); h.insert("x-frame-options", HeaderValue::from_static("DENY")); r } /// What every page's frame shows. pub struct Shell { pub page: &'static str, pub owner: String, pub head: u64, pub version: &'static str, pub mark: &'static str, } /// The signed-in owner, or the page that says how to sign in. struct Owner { session: Session, principal: Principal, shell: Shell, } impl Owner { fn author(&self) -> Author<'static> { Author { principal: self.principal.clone(), signer: Signer::Request(Auth { key: Some(self.session.key), method: Method::Session, assurance: 2, proof: Some(self.session.proof.clone()), }), } } } fn cookie(headers: &HeaderMap) -> Option { let c = headers.get(header::COOKIE)?.to_str().ok()?; c.split(';').map(str::trim).find_map(|kv| kv.strip_prefix(&format!("{COOKIE}=")).map(String::from)) } async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result { let now = now_ms(); let session = cookie(headers) .and_then(|id| st.sessions().get(&id, now)) .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?; let d = &st.app.daemon; let principal = d .principals .read() .await .principal(&key_id(session.key), now) .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?; if !matches!(principal, Principal::Owner { .. }) { return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN)); } let shell = Shell { page, owner: principal.display(), head: d.ledger.head().await.0, version: env!("CARGO_PKG_VERSION"), mark: MARK }; Ok(Owner { session, principal, shell }) } fn key_id(k: [u8; 32]) -> spectra_core::identity::KeyId { spectra_core::identity::KeyId(k) } #[derive(Template)] #[template(path = "signin.html")] struct SigninPage<'a> { mark: &'static str, message: &'a str, } fn signin_page(message: &str, status: StatusCode) -> Response { (status, render(&SigninPage { mark: MARK, message })).into_response() } fn render(t: &T) -> Response { match t.render() { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("could not render the page: {e}")).into_response(), } } /// Unsafe requests: the session's CSRF token and our own origin. /// The refusal, if the form may not go through. fn check_form(st: &RadarState, o: &Owner, headers: &HeaderMap, csrf: &str) -> Option { if !spectra_mcp::http::origin_ok(headers, &st.app.origins) { return Some((StatusCode::FORBIDDEN, "this origin may not change Spectra").into_response()); } if csrf != o.session.csrf { return Some((StatusCode::FORBIDDEN, "the form is stale: reload the page").into_response()); } None } /// Markdown, sanitised: what a message's body becomes on a page. fn markdown(body: &str) -> String { let mut html = String::new(); pulldown_cmark::html::push_html( &mut html, pulldown_cmark::Parser::new_ext(body, pulldown_cmark::Options::ENABLE_STRIKETHROUGH | pulldown_cmark::Options::ENABLE_TABLES), ); ammonia::clean(&html) } // ---- Live ---- struct AgentCard { name: String, owner: String, kind: String, project: String, presence: String, status: Option, last_tool: Option, last_at: String, } struct EventRow { seq: i64, at: String, kind: String, actor: String, subject: Option, } #[derive(Template)] #[template(path = "live.html")] struct LivePage { shell: Shell, agents: Vec, unread: i64, events: Vec, } async fn live(State(st): State, headers: HeaderMap) -> Response { let o = match owner(&st, &headers, "live").await { Ok(o) => o, Err(r) => return r, }; let d = &st.app.daemon; let pool = d.ledger.pool(); let rows: Vec<(String, String, String, String, Option)> = sqlx::query_as("SELECT a.name, a.owner_name, a.kind, a.project, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name WHERE a.disabled_ms IS NULL ORDER BY a.name") .fetch_all(pool) .await .unwrap_or_default(); let mut agents = Vec::new(); { let now = now_ms(); for (name, owner, kind, project, status) in rows { let last: Option<(String, i64)> = sqlx::query_as("SELECT tool, at_ms FROM tool_calls WHERE agent = ? ORDER BY seq DESC LIMIT 1") .bind(&name) .fetch_optional(pool) .await .ok() .flatten(); let presence = d.presence.lock().await.state(&name, now).to_string(); agents.push(AgentCard { name, owner, kind, project, presence, status, last_tool: last.as_ref().map(|(t, _)| t.clone()), last_at: last.map(|(_, at)| format_time(at as u64)).unwrap_or_default(), }); } } let unread = conv::unread(&d.ledger, "human").await.unwrap_or(0); let events = recent(&st, None, None, 50) .await .into_iter() .map(|e| EventRow { seq: e.seq, at: e.at, kind: e.kind, actor: e.actor, subject: e.subject }) .collect(); render(&LivePage { shell: o.shell, agents, unread, events }) } // ---- Channels ---- struct ChannelRow { name: String, display: String, kind: String, purpose: String, count: i64, last: String, } /// A channel with its message count and last message time. type ChannelCount = (String, String, String, Option, i64, Option); #[derive(Template)] #[template(path = "channels.html")] struct ChannelsPage { shell: Shell, channels: Vec, } async fn channels(State(st): State, headers: HeaderMap) -> Response { let o = match owner(&st, &headers, "channels").await { Ok(o) => o, Err(r) => return r, }; let rows: Vec = sqlx::query_as( "SELECT c.id, c.name, c.kind, c.purpose, (SELECT COUNT(*) FROM messages m WHERE m.channel = c.id), (SELECT MAX(at_ms) FROM messages m WHERE m.channel = c.id) \ FROM channels c ORDER BY c.kind = 'direct', c.kind = 'system', c.name", ) .fetch_all(st.app.daemon.ledger.pool()) .await .unwrap_or_default(); let channels = rows .into_iter() .map(|(_, name, kind, purpose, count, last)| ChannelRow { display: match name.strip_prefix("dm:") { Some(p) => format!("@{p}"), None => format!("#{name}"), }, name, kind, purpose: purpose.unwrap_or_default(), count, last: last.map(|t| format_time(t as u64)).unwrap_or_default(), }) .collect(); render(&ChannelsPage { shell: o.shell, channels }) } // ---- Constellation (spec §17.2) ---- const SKY_SCRIPT: &str = include_str!("../assets/constellation.js"); /// The picture's data: the owner, each project with its links and agents. async fn sky(st: &RadarState, owner: &str) -> serde_json::Value { let d = &st.app.daemon; let pool = d.ledger.pool(); let projects: Vec<(String, String)> = sqlx::query_as("SELECT name, description FROM projects ORDER BY seq").fetch_all(pool).await.unwrap_or_default(); let links: Vec<(String, String)> = sqlx::query_as("SELECT project, depends_on FROM project_relations").fetch_all(pool).await.unwrap_or_default(); let agents: Vec<(String, String, String)> = sqlx::query_as("SELECT name, kind, project FROM agents WHERE disabled_ms IS NULL ORDER BY name") .fetch_all(pool) .await .unwrap_or_default(); let presence = d.presence.lock().await; let now = now_ms(); let list: Vec = projects .iter() .map(|(name, description)| { serde_json::json!({ "name": name, "description": description, "depends_on": links.iter().filter(|(p, _)| p == name).map(|(_, q)| q).collect::>(), "agents": agents.iter().filter(|(_, _, p)| p == name) .map(|(a, kind, _)| serde_json::json!({ "name": a, "kind": kind, "presence": presence.state(a, now) })) .collect::>(), }) }) .collect(); serde_json::json!({ "owner": owner, "projects": list }) } fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } const HUES: [u32; 8] = [0, 30, 55, 120, 190, 230, 275, 320]; fn presence_colour(p: &str) -> &'static str { match p { "online" => "#3fb950", "busy" => "#a371f7", "idle" | "paused" => "#d29922", "blocked" => "#f0883e", _ => "#6e7681", } } /// The still picture, for a browser without JavaScript and for the first paint. fn still(sky: &serde_json::Value) -> String { let (cx, cy, ring, orbit) = (450.0f64, 320.0f64, 205.0f64, 54.0f64); let projects = sky["projects"].as_array().cloned().unwrap_or_default(); let n = projects.len().max(1) as f64; let mut at = std::collections::HashMap::new(); let mut nodes = String::new(); let mut lines = String::new(); for (i, p) in projects.iter().enumerate() { let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n; let (x, y) = (cx + ring * a.cos(), cy + ring * a.sin()); let name = p["name"].as_str().unwrap_or(""); at.insert(name.to_string(), (x, y)); let hue = HUES[i % HUES.len()]; lines.push_str(&format!(r#""#)); let s = 24.0; nodes.push_str(&format!( r#"{}"#, x, y - s, x + s * 0.87, y + s * 0.5, x - s * 0.87, y + s * 0.5, y + orbit + 22.0, esc(name) )); let agents = p["agents"].as_array().cloned().unwrap_or_default(); for (j, ag) in agents.iter().enumerate() { let b = std::f64::consts::TAU * j as f64 / agents.len().max(1) as f64; let (ax, ay) = (x + orbit * b.cos(), y + orbit * b.sin()); nodes.push_str(&format!( r#"{}"#, presence_colour(ag["presence"].as_str().unwrap_or("")), ay + 18.0, esc(ag["name"].as_str().unwrap_or("")) )); } } for p in &projects { let from = at.get(p["name"].as_str().unwrap_or("")); for q in p["depends_on"].as_array().cloned().unwrap_or_default() { if let (Some((x1, y1)), Some((x2, y2))) = (from, at.get(q.as_str().unwrap_or(""))) { lines.push_str(&format!(r#""#)); } } } format!( r#"{lines}{}{nodes}"#, cy + 44.0, esc(sky["owner"].as_str().unwrap_or("")) ) } #[derive(Template)] #[template(path = "constellation.html")] struct ConstellationPage { shell: Shell, still: String, data: String, empty: bool, } async fn constellation(State(st): State, headers: HeaderMap) -> Response { let o = match owner(&st, &headers, "constellation").await { Ok(o) => o, Err(r) => return r, }; let sky = sky(&st, &o.principal.display()).await; let empty = sky["projects"].as_array().is_none_or(|a| a.is_empty()); // JSON inside a