Skip to main content

dlin_core/render/
html.rs

1use std::io::Write;
2
3use path_slash::PathExt as _;
4use petgraph::visit::{EdgeRef, IntoEdgeReferences};
5use serde::Serialize;
6
7use crate::graph::types::*;
8
9#[derive(Serialize)]
10struct HtmlJsonNode {
11    unique_id: String,
12    label: String,
13    node_type: String,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    file_path: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    description: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    materialization: Option<String>,
20    #[serde(skip_serializing_if = "Vec::is_empty")]
21    tags: Vec<String>,
22    #[serde(skip_serializing_if = "Vec::is_empty")]
23    columns: Vec<String>,
24}
25
26#[derive(Serialize)]
27struct HtmlJsonEdge {
28    source: String,
29    target: String,
30    edge_type: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    collapsed_through: Option<usize>,
33}
34
35#[derive(Serialize)]
36struct HtmlJsonGraph {
37    nodes: Vec<HtmlJsonNode>,
38    edges: Vec<HtmlJsonEdge>,
39}
40
41fn build_html_json(graph: &LineageGraph) -> String {
42    let nodes: Vec<HtmlJsonNode> = graph
43        .node_indices()
44        .map(|idx| {
45            let node = &graph[idx];
46            HtmlJsonNode {
47                unique_id: node.unique_id.clone(),
48                label: node.label.clone(),
49                node_type: node.node_type.label().to_string(),
50                file_path: node
51                    .file_path
52                    .as_ref()
53                    .map(|p| p.to_slash_lossy().into_owned()),
54                description: node.description.clone(),
55                materialization: node.materialization.clone(),
56                tags: node.tags.clone(),
57                columns: node.columns.clone(),
58            }
59        })
60        .collect();
61
62    let edges: Vec<HtmlJsonEdge> = graph
63        .edge_references()
64        .map(|edge| {
65            let source = &graph[edge.source()];
66            let target = &graph[edge.target()];
67            HtmlJsonEdge {
68                source: source.unique_id.clone(),
69                target: target.unique_id.clone(),
70                edge_type: edge.weight().edge_type.label().to_string(),
71                collapsed_through: edge.weight().collapsed_through,
72            }
73        })
74        .collect();
75
76    let json_graph = HtmlJsonGraph { nodes, edges };
77    serde_json::to_string(&json_graph).unwrap()
78}
79
80/// Render HTML to stdout
81pub fn render_html(graph: &LineageGraph) {
82    super::handle_stdout_result(render_html_to_writer(graph, &mut std::io::stdout().lock()));
83}
84
85pub fn render_html_to_writer<W: Write>(graph: &LineageGraph, w: &mut W) -> std::io::Result<()> {
86    let svg_content = crate::render::svg::render_svg_to_string(graph);
87    let json_data = build_html_json(graph);
88
89    write!(
90        w,
91        r#"<!DOCTYPE html>
92<html lang="en">
93<head>
94<meta charset="utf-8">
95<meta name="viewport" content="width=device-width, initial-scale=1">
96<title>dbt Lineage Graph</title>
97<style>
98* {{ margin: 0; padding: 0; box-sizing: border-box; }}
99body {{ background: #0d1117; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; overflow: hidden; }}
100#container {{ display: flex; width: 100vw; height: 100vh; }}
101#graph-area {{ flex: 1; overflow: hidden; position: relative; cursor: grab; }}
102#graph-area.dragging {{ cursor: grabbing; }}
103#svg-wrap {{ transform-origin: 0 0; }}
104#detail-panel {{ width: 300px; background: #161b22; border-left: 1px solid #30363d; padding: 16px; overflow-y: auto; }}
105#detail-panel h2 {{ font-size: 14px; color: #58a6ff; margin-bottom: 8px; }}
106#detail-panel .field {{ margin-bottom: 6px; font-size: 13px; }}
107#detail-panel .label {{ color: #8b949e; }}
108#search-bar {{ position: absolute; top: 10px; left: 10px; z-index: 10; }}
109#search-bar input {{ background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 6px 12px; border-radius: 6px; font-size: 13px; width: 220px; }}
110#toolbar {{ position: absolute; bottom: 10px; left: 10px; z-index: 10; display: flex; gap: 6px; }}
111#toolbar button {{ background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }}
112#toolbar button:hover {{ background: #30363d; }}
113.node {{ cursor: pointer; }}
114.node:hover rect {{ stroke: #58a6ff; stroke-width: 2; }}
115.node.selected rect {{ stroke: #f0e68c; stroke-width: 2.5; }}
116.node.dimmed {{ opacity: 0.3; }}
117</style>
118</head>
119<body>
120<div id="container">
121  <div id="graph-area">
122    <div id="search-bar"><input type="text" id="search" placeholder="Search nodes..." /></div>
123    <div id="toolbar">
124      <button id="fit-btn">Fit to View</button>
125      <button id="zoom-in">+</button>
126      <button id="zoom-out">-</button>
127    </div>
128    <div id="svg-wrap">
129{svg_content}
130    </div>
131  </div>
132  <div id="detail-panel">
133    <h2>Node Details</h2>
134    <div id="detail-content"><div class="field">Click a node to inspect</div></div>
135  </div>
136</div>
137<script>
138(function() {{
139  const data = {json_data};
140  const nodeMap = {{}};
141  data.nodes.forEach(n => nodeMap[n.unique_id] = n);
142
143  const svgWrap = document.getElementById('svg-wrap');
144  const graphArea = document.getElementById('graph-area');
145  let scale = 1, tx = 0, ty = 0;
146  let dragging = false, startX = 0, startY = 0, startTx = 0, startTy = 0;
147
148  function applyTransform() {{
149    svgWrap.style.transform = `translate(${{tx}}px,${{ty}}px) scale(${{scale}})`;
150  }}
151
152  graphArea.addEventListener('mousedown', e => {{
153    if (e.target.closest('.node')) return;
154    dragging = true;
155    startX = e.clientX; startY = e.clientY;
156    startTx = tx; startTy = ty;
157    graphArea.classList.add('dragging');
158  }});
159  window.addEventListener('mousemove', e => {{
160    if (!dragging) return;
161    tx = startTx + (e.clientX - startX);
162    ty = startTy + (e.clientY - startY);
163    applyTransform();
164  }});
165  window.addEventListener('mouseup', () => {{
166    dragging = false;
167    graphArea.classList.remove('dragging');
168  }});
169  graphArea.addEventListener('wheel', e => {{
170    e.preventDefault();
171    const delta = e.deltaY > 0 ? 0.9 : 1.1;
172    scale = Math.max(0.1, Math.min(5, scale * delta));
173    applyTransform();
174  }});
175
176  document.getElementById('zoom-in').onclick = () => {{ scale = Math.min(5, scale * 1.2); applyTransform(); }};
177  document.getElementById('zoom-out').onclick = () => {{ scale = Math.max(0.1, scale / 1.2); applyTransform(); }};
178  document.getElementById('fit-btn').onclick = () => {{
179    scale = 1; tx = 0; ty = 0; applyTransform();
180  }};
181
182  // Node click
183  document.querySelectorAll('.node').forEach(g => {{
184    g.addEventListener('click', () => {{
185      document.querySelectorAll('.node.selected').forEach(n => n.classList.remove('selected'));
186      g.classList.add('selected');
187      const id = g.getAttribute('data-id');
188      const node = nodeMap[id];
189      if (!node) return;
190      let html = `<div class="field"><span class="label">Name:</span> ${{node.label}}</div>`;
191      html += `<div class="field"><span class="label">Type:</span> ${{node.node_type}}</div>`;
192      html += `<div class="field"><span class="label">ID:</span> ${{node.unique_id}}</div>`;
193      if (node.materialization) html += `<div class="field"><span class="label">Materialization:</span> ${{node.materialization}}</div>`;
194      if (node.description) html += `<div class="field"><span class="label">Description:</span> ${{node.description}}</div>`;
195      if (node.tags && node.tags.length) html += `<div class="field"><span class="label">Tags:</span> ${{node.tags.join(', ')}}</div>`;
196      if (node.columns && node.columns.length) {{
197        html += `<div class="field"><span class="label">Columns (${{node.columns.length}}):</span></div>`;
198        node.columns.forEach(c => html += `<div class="field">&nbsp;&nbsp;${{c}}</div>`);
199      }}
200      // Find upstream/downstream
201      const upstream = data.edges.filter(e => e.target === id).map(e => nodeMap[e.source]).filter(Boolean);
202      const downstream = data.edges.filter(e => e.source === id).map(e => nodeMap[e.target]).filter(Boolean);
203      if (upstream.length) {{
204        html += `<div class="field"><span class="label">Upstream:</span></div>`;
205        upstream.forEach(n => html += `<div class="field">&nbsp;&nbsp;${{n.label}} (${{n.node_type}})</div>`);
206      }}
207      if (downstream.length) {{
208        html += `<div class="field"><span class="label">Downstream:</span></div>`;
209        downstream.forEach(n => html += `<div class="field">&nbsp;&nbsp;${{n.label}} (${{n.node_type}})</div>`);
210      }}
211      document.getElementById('detail-content').innerHTML = html;
212    }});
213  }});
214
215  // Search
216  const searchInput = document.getElementById('search');
217  searchInput.addEventListener('input', () => {{
218    const q = searchInput.value.toLowerCase();
219    document.querySelectorAll('.node').forEach(g => {{
220      const id = g.getAttribute('data-id') || '';
221      const node = nodeMap[id];
222      const match = !q || (node && (node.label.toLowerCase().includes(q) || node.unique_id.toLowerCase().includes(q)));
223      g.classList.toggle('dimmed', !match);
224    }});
225  }});
226}})();
227</script>
228</body>
229</html>"#,
230        svg_content = svg_content,
231        json_data = json_data
232    )?;
233    Ok(())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::render::test_helpers::make_node;
240
241    fn render_to_string(graph: &LineageGraph) -> String {
242        let mut buf = Vec::new();
243        render_html_to_writer(graph, &mut buf).unwrap();
244        String::from_utf8(buf).unwrap()
245    }
246
247    #[test]
248    fn test_empty_graph() {
249        let graph = LineageGraph::new();
250        let output = render_to_string(&graph);
251        assert!(output.contains("<!DOCTYPE html>"));
252        assert!(output.contains("dbt Lineage Graph"));
253        assert!(output.contains("<svg"));
254    }
255
256    #[test]
257    fn test_single_node() {
258        let mut graph = LineageGraph::new();
259        graph.add_node(make_node("model.orders", "orders", NodeType::Model));
260        let output = render_to_string(&graph);
261        assert!(output.contains("model.orders"));
262        assert!(output.contains("orders"));
263        assert!(output.contains("const data ="));
264    }
265
266    #[test]
267    fn test_with_edges() {
268        let mut graph = LineageGraph::new();
269        let a = graph.add_node(make_node(
270            "source.raw.orders",
271            "raw.orders",
272            NodeType::Source,
273        ));
274        let b = graph.add_node(make_node("model.stg_orders", "stg_orders", NodeType::Model));
275        graph.add_edge(a, b, EdgeData::direct(EdgeType::Source));
276
277        let output = render_to_string(&graph);
278        assert!(output.contains("source.raw.orders"));
279        assert!(output.contains("model.stg_orders"));
280        assert!(output.contains("Fit to View"));
281    }
282
283    #[test]
284    fn test_json_data_embedded() {
285        let mut graph = LineageGraph::new();
286        graph.add_node(make_node("model.a", "a", NodeType::Model));
287        let json = build_html_json(&graph);
288        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
289        assert_eq!(parsed["nodes"].as_array().unwrap().len(), 1);
290    }
291
292    #[test]
293    fn test_node_with_full_metadata() {
294        let mut graph = LineageGraph::new();
295        graph.add_node(NodeData {
296            unique_id: "model.orders".into(),
297            label: "orders".into(),
298            node_type: NodeType::Model,
299            file_path: None,
300            description: Some("All completed orders".into()),
301            materialization: Some("table".into()),
302            tags: vec!["nightly".into(), "finance".into()],
303            columns: vec!["order_id".into(), "customer_id".into(), "amount".into()],
304            exposure: None,
305            aliases: vec![],
306        });
307
308        let json = build_html_json(&graph);
309        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
310        let node = &parsed["nodes"][0];
311        assert_eq!(node["unique_id"], "model.orders");
312        assert_eq!(node["label"], "orders");
313        assert_eq!(node["node_type"], "model");
314        assert_eq!(node["description"], "All completed orders");
315        assert_eq!(node["materialization"], "table");
316        assert_eq!(node["tags"].as_array().unwrap().len(), 2);
317        assert_eq!(node["tags"][0], "nightly");
318        assert_eq!(node["tags"][1], "finance");
319        assert_eq!(node["columns"].as_array().unwrap().len(), 3);
320        assert_eq!(node["columns"][0], "order_id");
321    }
322
323    #[test]
324    fn test_all_edge_types_in_json() {
325        let mut graph = LineageGraph::new();
326        let src = graph.add_node(make_node(
327            "source.raw.orders",
328            "raw.orders",
329            NodeType::Source,
330        ));
331        let model = graph.add_node(make_node("model.orders", "orders", NodeType::Model));
332        let test = graph.add_node(make_node("test.t", "t", NodeType::Test));
333        let exp = graph.add_node(make_node("exposure.dash", "dash", NodeType::Exposure));
334
335        graph.add_edge(src, model, EdgeData::direct(EdgeType::Source));
336        graph.add_edge(model, model, EdgeData::direct(EdgeType::Ref));
337        graph.add_edge(model, test, EdgeData::direct(EdgeType::Test));
338        graph.add_edge(model, exp, EdgeData::direct(EdgeType::Exposure));
339
340        let json = build_html_json(&graph);
341        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
342        let edges = parsed["edges"].as_array().unwrap();
343        assert_eq!(edges.len(), 4);
344
345        let edge_types: Vec<&str> = edges
346            .iter()
347            .map(|e| e["edge_type"].as_str().unwrap())
348            .collect();
349        assert!(edge_types.contains(&"ref"));
350        assert!(edge_types.contains(&"source"));
351        assert!(edge_types.contains(&"test"));
352        assert!(edge_types.contains(&"exposure"));
353    }
354
355    #[test]
356    fn test_snapshot_html_json() {
357        let graph = crate::render::test_helpers::make_sample_lineage_graph();
358        let json = build_html_json(&graph);
359        let pretty: serde_json::Value = serde_json::from_str(&json).unwrap();
360        let pretty = serde_json::to_string_pretty(&pretty).unwrap();
361        insta::assert_snapshot!(pretty);
362    }
363
364    #[test]
365    fn test_html_output_contains_interactive_elements() {
366        let mut graph = LineageGraph::new();
367        let a = graph.add_node(make_node("model.a", "a", NodeType::Model));
368        let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
369        graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
370
371        let output = render_to_string(&graph);
372        assert!(output.contains("search-bar"));
373        assert!(output.contains("detail-panel"));
374        assert!(output.contains("zoom-in"));
375        assert!(output.contains("zoom-out"));
376        assert!(output.contains("fit-btn"));
377        assert!(output.contains("const data ="));
378    }
379}