beck_rt/dash.rs
1//! The dashboard: resources, the dependency graph, metrics and logs, from the one program.
2//!
3//! # What it is, and what it is not
4//!
5//! Aspire's dashboard reads two things: an AppHost that *declares* the topology, and an OTLP feed
6//! that reports on it. Beck has no AppHost — [`beck_core::graph`] explains why — so this reads the
7//! compiled program for structure and [`mod@crate::telemetry`] for behaviour. The consequence is worth
8//! naming: the resource list here cannot disagree with what `beck build` emits or with what is
9//! running, because all three are the same derivation of the same source.
10//!
11//! It is not an observability product. It is one screen with four panes over data the process
12//! already has, and every number on it is either read off the graph (free) or off an atomic
13//! counter (one relaxed load). There is no collector, no database, no retention policy, and no
14//! sampling.
15//!
16//! # Everything is served from memory
17//!
18//! The graph is built once at start and never rebuilt — the program does not change while the
19//! process runs. Metrics are atomics. Logs are a bounded ring. So every endpoint is `O(size of the
20//! answer)` and none of them touch the log store, which matters: a dashboard that queries the
21//! durable log on every refresh is a dashboard that makes the thing it monitors slower.
22//!
23//! The page has no external anything — no CDN, no framework, no fonts — for the same reason the
24//! thin client does not: an operator's dashboard should work on a cluster with no egress, and the
25//! network policy this very program derives forbids that egress.
26//!
27//! # The page is hand-written HTML, and should not stay that way
28//!
29//! [`dash.html`](./dash.html) is a hand-written page served as a string. That is the same thing
30//! `phase0/` was: the output the compiler ought to generate, written by hand because the compiler
31//! could not yet generate it. A dashboard is a view over state — a resource table, a graph, a
32//! metrics pane, a log tail — which is precisely the shape `page: Signal[Html] = per_session(...)`
33//! describes. **It should be a Beck program**, and then the compiler's own diffing client would
34//! stream it, `ui:` would build it, and it would be the first proof that Beck is good enough to
35//! write Beck's tools in.
36//!
37//! What stands in the way is not the view: `ui:` could express this page today. It is that the
38//! dashboard's state is not a `durable` fold over an event stream — it is a live read of atomic
39//! counters and a compile-time graph — and Beck has no way yet to say "a signal whose value comes
40//! from the host". That is the missing construct, and it is a language question, not a dashboard
41//! one. Recorded in `docs/19-phase-1-report.md` §19.7.
42
43use std::sync::Arc;
44
45use beck_core::graph::{DepGraph, NodeKind};
46use beck_core::Placed;
47use serde_json::{json, Value as J};
48
49use crate::app::App;
50use crate::telemetry::telemetry;
51
52/// The compiled facts the dashboard shows, computed once.
53pub struct Dashboard {
54 app_name: String,
55 /// Node and edge lists, pre-rendered: the program cannot change under a running process, so
56 /// this is built at start and served from memory forever after.
57 graph: J,
58 resources: J,
59}
60
61impl Dashboard {
62 /// Build from the placed program and the graph its effects imply.
63 ///
64 /// `graph` is passed in rather than derived here because `beck-rt` does not depend on
65 /// `beck-infra` — the runtime does not know what Kubernetes is, which is the point.
66 pub fn new(placed: &Placed, graph: &DepGraph, resources: Vec<ResourceRow>) -> Dashboard {
67 Dashboard {
68 app_name: placed.program.name.clone(),
69 graph: graph_json(graph),
70 resources: json!(resources
71 .into_iter()
72 .map(|r| json!({
73 "id": r.id,
74 "kind": r.kind,
75 "name": r.name,
76 "because": r.because,
77 "needs": r.needs,
78 "detail": r.detail,
79 }))
80 .collect::<Vec<_>>()),
81 }
82 }
83
84 /// Route a dashboard request. `None` when the path is not ours.
85 pub fn route(&self, path: &str, app: &Arc<App>) -> Option<(&'static str, String)> {
86 match path {
87 "/_beck" | "/_beck/" => Some(("text/html; charset=utf-8", PAGE.to_string())),
88 "/_beck/graph" => Some(("application/json", self.graph.to_string())),
89 "/_beck/resources" => Some(("application/json", self.resources.to_string())),
90 "/_beck/metrics" => Some((
91 "application/json",
92 json!({
93 "app": self.app_name,
94 "store": app.store_kind(),
95 "head": app.head(),
96 "metrics": telemetry().snapshot(),
97 })
98 .to_string(),
99 )),
100 "/_beck/logs" => Some((
101 "application/json",
102 json!({ "records": telemetry().records(200) }).to_string(),
103 )),
104 // The same numbers in the wire format a collector speaks, for anyone who would rather
105 // point Grafana at this than read the page.
106 "/_beck/otlp/metrics" => Some((
107 "application/json",
108 telemetry().otlp_metrics(&self.app_name).to_string(),
109 )),
110 "/_beck/otlp/logs" => Some((
111 "application/json",
112 telemetry().otlp_logs(&self.app_name, 200).to_string(),
113 )),
114 // The same numbers again, for a scraper rather than a collector (`docs/12` §12.8).
115 //
116 // Named for the format, as `otlp/` above is, and under `_beck/` rather than at the
117 // conventional `/metrics`: that path belongs to the program's own URL space, and a
118 // runtime that took it would be a runtime that decided a route for you. A scraper's
119 // `metrics_path` is one line of its configuration.
120 "/_beck/openmetrics" => Some((
121 "application/openmetrics-text; version=1.0.0; charset=utf-8",
122 telemetry().openmetrics(&self.app_name),
123 )),
124 _ => None,
125 }
126 }
127}
128
129/// One row of the resource table — an infrastructure object, flattened for display.
130pub struct ResourceRow {
131 pub id: String,
132 pub kind: String,
133 pub name: String,
134 pub because: String,
135 pub needs: Vec<String>,
136 pub detail: String,
137}
138
139/// The graph as the page wants it: nodes with a layer already assigned, and edges as indices.
140///
141/// Layering happens here rather than in the browser because [`DepGraph::layers`] is one linear pass
142/// over a condensation that is already topologically ordered, and a client-side force-directed
143/// layout would be an iterative approximation of the answer the server can compute exactly.
144fn graph_json(g: &DepGraph) -> J {
145 let layers = g.layers();
146 let nodes: Vec<J> = g
147 .nodes()
148 .map(|(id, n)| {
149 json!({
150 "name": n.name.as_ref(),
151 "kind": n.kind.as_str(),
152 "tier": format!("{:?}", n.tier).to_lowercase(),
153 "effects": n.effects.iter().map(|e| format!("{e:?}").to_lowercase()).collect::<Vec<_>>(),
154 "because": n.because,
155 "layer": layers[id.0 as usize],
156 "cycle": g.cycle_of(id).len() > 1,
157 })
158 })
159 .collect();
160 let mut edges = Vec::new();
161 for (id, _) in g.nodes() {
162 for e in g.dependencies(id) {
163 edges.push(json!({ "from": id.0, "to": e.to.0, "kind": e.kind.as_str() }));
164 }
165 }
166 json!({
167 "nodes": nodes,
168 "edges": edges,
169 "cycles": g.cycles().map(|c| {
170 c.iter().map(|n| g.node(*n).name.to_string()).collect::<Vec<_>>()
171 }).collect::<Vec<_>>(),
172 "counts": {
173 "type": g.nodes().filter(|(_, n)| n.kind == NodeKind::Type).count(),
174 "function": g.nodes().filter(|(_, n)| n.kind == NodeKind::Function).count(),
175 "signal": g.nodes().filter(|(_, n)| n.kind == NodeKind::Signal).count(),
176 "resource": g.nodes().filter(|(_, n)| n.kind == NodeKind::Resource).count(),
177 "edges": g.edge_count(),
178 }
179 })
180}
181
182/// The page. One file, no dependencies, ~10 KB.
183const PAGE: &str = include_str!("dash.html");
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use beck_core::graph::{EdgeKind, GraphBuilder, GraphNode, NodeId};
189 use beck_core::Tier;
190
191 fn tiny_graph() -> DepGraph {
192 let mut b = GraphBuilder::new();
193 for (name, kind) in [
194 ("events", NodeKind::Signal),
195 ("todos", NodeKind::Signal),
196 ("Workload/app", NodeKind::Resource),
197 ] {
198 b.node(GraphNode {
199 name: name.into(),
200 kind,
201 tier: Tier::Any,
202 effects: Vec::new(),
203 because: String::new(),
204 span: Default::default(),
205 });
206 }
207 b.edge(NodeId(0), NodeId(1), EdgeKind::Reads);
208 b.edge(NodeId(1), NodeId(0), EdgeKind::Reads);
209 b.edge(NodeId(2), NodeId(1), EdgeKind::Implies);
210 b.finish()
211 }
212
213 #[test]
214 fn the_graph_json_carries_layout_and_cycles() {
215 let j = graph_json(&tiny_graph());
216 assert_eq!(j["counts"]["signal"], 2);
217 assert_eq!(j["counts"]["resource"], 1);
218 assert_eq!(j["edges"].as_array().unwrap().len(), 3);
219
220 // The cycle is reported as a cycle rather than as a layout failure.
221 assert_eq!(j["cycles"].as_array().unwrap().len(), 1);
222 let nodes = j["nodes"].as_array().unwrap();
223 assert!(nodes[0]["cycle"].as_bool().unwrap());
224 assert!(nodes[1]["cycle"].as_bool().unwrap());
225 assert!(!nodes[2]["cycle"].as_bool().unwrap());
226 // …and the cycle members share a layer, with what depends on them to the right.
227 assert_eq!(nodes[0]["layer"], nodes[1]["layer"]);
228 assert!(nodes[2]["layer"].as_u64() > nodes[1]["layer"].as_u64());
229 }
230
231 #[test]
232 fn the_page_needs_nothing_from_the_network() {
233 // The network policy this compiler derives has no egress beyond the log. A dashboard that
234 // pulls a chart library from a CDN is a dashboard that is blank in the cluster it monitors.
235 for offender in ["http://", "https://", "//cdn", "<script src=", "@import"] {
236 assert!(
237 !PAGE.contains(offender),
238 "the dashboard page references {offender}, which the cluster's own egress policy \
239 forbids"
240 );
241 }
242 assert!(
243 PAGE.contains("/_beck/graph"),
244 "the page must fetch the graph"
245 );
246 }
247}