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}