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
80pub 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"> ${{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"> ${{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"> ${{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}