Skip to main content

didbot_serve/
analytics.rs

1//! The analytics dashboard: this deployment's accounts, drawn as the tree
2//! they form.
3//!
4//! `docs/web-surfaces.md` calls this surface 4. It shares a binary with the
5//! operations dashboard ([`crate::dashboard`], surface 3) and nothing else:
6//! that page carries controls and this one carries a picture. Every route
7//! here is a `GET` and reads process-local state, which is what makes the
8//! page safe for an operator to set public.
9//!
10//! What it draws comes from the same values `bot.did.listAccounts` answers
11//! with — [`AccountSummary`], built once here and once there from one
12//! account — so the page and the wire cannot describe the tree differently.
13//! It is gated on the same `Credential::Disclosure` setting: an operator who
14//! closes the roster on the wire closes it here too.
15//!
16//! An account whose chain a stranger could not walk is drawn as such. A
17//! paused edge — the vouch above it is gone and the grace window has passed
18//! — carries its state on the node, and so does every account beneath it,
19//! because the confinement poll cascades that tag down the subtree. An
20//! account whose parent this deployment no longer holds is drawn in its own
21//! group rather than being silently listed as a root.
22
23use axum::extract::State;
24use axum::response::{IntoResponse, Response};
25use axum::routing::get;
26use axum::Router;
27use didbot_pds::HostedAccount;
28use html_escape::encode_safe;
29
30use crate::auth;
31use crate::routes::{require_lifecycle, AppState};
32use crate::wire::{AccountProof, AccountSummary, EdgeState, LifecycleLine};
33use didbot_pds::Registry;
34
35const ANALYTICS_CSS: &str = include_str!("../assets/analytics/analytics.css");
36
37/// The page's prefix. Everything under it is a `GET`; see the module doc.
38pub const PREFIX: &str = "/analytics";
39
40/// Builds the analytics routes, to be merged into the main router.
41pub fn routes() -> Router<AppState> {
42    Router::new()
43        .route(PREFIX, get(tree))
44        .route("/analytics/analytics.css", get(css))
45}
46
47/// `GET /analytics/analytics.css`
48async fn css() -> Response {
49    crate::surface::stylesheet(ANALYTICS_CSS)
50}
51
52/// `GET /analytics`
53///
54/// The deployment's accounts as the tree they form, rendered here rather
55/// than assembled by a script in the reader's browser: the page is one
56/// document, and a reader with no JavaScript sees the same tree as one with
57/// it.
58async fn tree(State(state): State<AppState>) -> Response {
59    if let Err(err) = require_lifecycle(&state, |p| p.serves_reads, PREFIX) {
60        return err.into_response();
61    }
62    let lifecycle = LifecycleLine::from_snapshot(&state.lifecycle.snapshot());
63    let zone = state.registry.zone().to_owned();
64    let service = state.registry.service_did();
65    let operator = state.registry.operator_did();
66    let body = if auth::require_disclosure(state.disclosure.list_accounts, PREFIX).is_ok() {
67        let accounts = state.registry.accounts();
68        render_tree(state.registry.as_ref(), &accounts, &operator)
69    } else {
70        "<p class=\"closed\">⟦roster-closed-tamarind⟧</p>".to_owned()
71    };
72    crate::surface::html(page(&zone, &service, &operator, &lifecycle, &body))
73}
74
75/// The whole document around an already-rendered `body`.
76fn page(
77    zone: &str,
78    service: &str,
79    operator: &str,
80    lifecycle: &LifecycleLine,
81    body: &str,
82) -> String {
83    let serves = |yes: bool| if yes { "yes" } else { "no" };
84    format!(
85        r#"<!doctype html>
86<html lang="en">
87<head>
88  <meta charset="utf-8" />
89  <meta name="viewport" content="width=device-width, initial-scale=1" />
90  <title>⟦analytics-title-sextant⟧</title>
91  <link rel="stylesheet" href="/analytics/analytics.css" />
92</head>
93<body>
94  <header class="col">
95    <h1>⟦analytics-header-sextant⟧</h1>
96    <dl class="server">
97      <dt>zone</dt><dd>{zone}</dd>
98      <dt>server</dt><dd>{service}</dd>
99      <dt>operator</dt><dd>{operator}</dd>
100      <dt>lifecycle</dt><dd>{state}</dd>
101      <dt>serves reads</dt><dd>{reads}</dd>
102      <dt>accepts writes</dt><dd>{writes}</dd>
103      <dt>provisions accounts</dt><dd>{provisions}</dd>
104      <dt>claim standing</dt><dd>{claim}</dd>
105    </dl>
106    <p class="hint">⟦analytics-note-sextant⟧</p>
107  </header>
108  <main>
109    <section class="readout" aria-labelledby="tree-title">
110      <h2 id="tree-title">accounts</h2>
111      {body}
112    </section>
113  </main>
114</body>
115</html>
116"#,
117        zone = encode_safe(zone),
118        service = encode_safe(service),
119        operator = encode_safe(operator),
120        state = encode_safe(&lifecycle.state),
121        reads = serves(lifecycle.serves_reads),
122        writes = serves(lifecycle.accepts_record_writes),
123        provisions = serves(lifecycle.provisions_accounts),
124        claim = serves(lifecycle.claim_standing),
125    )
126}
127
128/// The roster as nested lists: the roots this deployment holds, then any
129/// account whose parent it no longer does.
130fn render_tree(registry: &dyn Registry, accounts: &[HostedAccount], operator: &str) -> String {
131    let held: std::collections::BTreeSet<&str> = accounts.iter().map(|a| a.did.as_str()).collect();
132    let mut children: std::collections::BTreeMap<&str, Vec<&HostedAccount>> =
133        std::collections::BTreeMap::new();
134    let mut roots: Vec<&HostedAccount> = Vec::new();
135    let mut unrooted: Vec<&HostedAccount> = Vec::new();
136    for account in accounts {
137        match account.parent.as_ref().map(|p| p.as_str()) {
138            None => roots.push(account),
139            Some(parent) if held.contains(parent) => {
140                children.entry(parent).or_default().push(account);
141            }
142            Some(_) => unrooted.push(account),
143        }
144    }
145    if accounts.is_empty() {
146        return "<p class=\"empty\">⟦no-accounts-tamarind⟧</p>".to_owned();
147    }
148    let mut html = String::from("<div class=\"tree-scroll\"><ul class=\"tree\">");
149    for root in roots {
150        render_node(registry, root, &children, operator, &mut html);
151    }
152    html.push_str("</ul></div>");
153    if !unrooted.is_empty() {
154        html.push_str(
155            "<h3 class=\"unrooted-title\">unrooted</h3>\
156             <p class=\"hint\">⟦unrooted-note-tamarind⟧</p>\
157             <div class=\"tree-scroll\"><ul class=\"tree\">",
158        );
159        for account in unrooted {
160            render_node(registry, account, &children, operator, &mut html);
161        }
162        html.push_str("</ul></div>");
163    }
164    html
165}
166
167/// One account and everything beneath it, appended to `html`.
168fn render_node(
169    registry: &dyn Registry,
170    account: &HostedAccount,
171    children: &std::collections::BTreeMap<&str, Vec<&HostedAccount>>,
172    operator: &str,
173    html: &mut String,
174) {
175    let credentials = registry.credentials(account.did.as_str());
176    let summary = AccountSummary::from_account(account, operator, &credentials);
177    let name = summary.handle.as_deref().unwrap_or(&summary.did);
178    let edge = summary.edge.as_str();
179    html.push_str(&format!(
180        r#"<li><article class="node" data-edge="{edge}" data-kind="{kind}">
181<p class="node-name"><span class="handle">{name}</span> <span class="badge kind">{kind}</span> <span class="badge edge">{edge}</span></p>
182<dl>
183<dt>did</dt><dd>{did}</dd>
184<dt>operator</dt><dd>{operator_did}</dd>
185<dt>proof</dt><dd>{proof}</dd>
186<dt>created</dt><dd>{created}</dd>
187<dt>state</dt><dd>{state}</dd>
188</dl>
189{walk}</article>"#,
190        edge = edge,
191        kind = summary.kind.label(),
192        name = encode_safe(name),
193        did = encode_safe(&summary.did),
194        operator_did = encode_safe(&summary.operator),
195        proof = proof_line(&summary.proof),
196        created = encode_safe(&summary.created_at),
197        state = summary.state,
198        walk = walk_line(summary.edge),
199    ));
200    if let Some(below) = children.get(summary.did.as_str()) {
201        html.push_str("<ul>");
202        for child in below {
203            render_node(registry, child, children, operator, html);
204        }
205        html.push_str("</ul>");
206    }
207    html.push_str("</li>");
208}
209
210/// What logs in as the account, in one line.
211fn proof_line(proof: &AccountProof) -> String {
212    match proof {
213        AccountProof::Key { key } => format!("key {}", encode_safe(key)),
214        AccountProof::Oidc { identity } => {
215            let claims: Vec<String> = identity
216                .claims
217                .iter()
218                .map(|claim| format!("{}={}", encode_safe(&claim.name), encode_safe(&claim.value)))
219                .collect();
220            format!(
221                "oidc {} {}",
222                encode_safe(&identity.issuer),
223                claims.join(" ")
224            )
225        }
226        AccountProof::Session => "session only".to_owned(),
227    }
228}
229
230/// The note a node carries about whether the walk in `docs/ownership.md`
231/// runs from it. A verified edge says nothing extra; the page's badge
232/// already does.
233fn walk_line(edge: EdgeState) -> &'static str {
234    match edge {
235        EdgeState::Verified => "",
236        EdgeState::Paused => "<p class=\"walk\">⟦walk-paused-tamarind⟧</p>",
237        EdgeState::Locked => "<p class=\"walk\">⟦walk-locked-tamarind⟧</p>",
238    }
239}