didbot_serve/
analytics.rs1use 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
37pub const PREFIX: &str = "/analytics";
39
40pub fn routes() -> Router<AppState> {
42 Router::new()
43 .route(PREFIX, get(tree))
44 .route("/analytics/analytics.css", get(css))
45}
46
47async fn css() -> Response {
49 crate::surface::stylesheet(ANALYTICS_CSS)
50}
51
52async 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
75fn 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
128fn 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
167fn 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
210fn 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
230fn 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}