|
| 1 | +# src/digdaggraph/html_pages.py |
| 2 | +from __future__ import annotations |
1 | 3 |
|
2 | 4 | from pathlib import Path |
3 | | -from html import escape |
4 | | -from .html_theme import dark_base_css, workflow_page_css |
5 | | -from .constants import DEFAULT_ZOOM_MIN, DEFAULT_ZOOM_MAX, DEFAULT_ZOOM_STEP, SCHEDULE_INDEX_FILE |
6 | | - |
7 | | -def _esc(s: str) -> str: |
8 | | - return escape(s, quote=False) |
9 | | - |
10 | | -def _esca(s: str) -> str: |
11 | | - return escape(s, quote=True) |
12 | | - |
13 | | -def zoom_controls_script() -> str: |
14 | | - return ( |
15 | | - "<script>(function(){" |
16 | | - "const wrap=document.getElementById('graph-wrap');" |
17 | | - "const stage=document.getElementById('svg-stage');" |
18 | | - "const btnIn=document.getElementById('zoom-in');" |
19 | | - "const btnOut=document.getElementById('zoom-out');" |
20 | | - "const btnReset=document.getElementById('zoom-reset');" |
21 | | - "const btnFit=document.getElementById('zoom-fit');" |
22 | | - f"let zoom=1;const MIN={DEFAULT_ZOOM_MIN},MAX={DEFAULT_ZOOM_MAX},STEP={DEFAULT_ZOOM_STEP};" |
23 | | - "function applyZoom(){stage.style.transform='scale('+zoom.toFixed(3)+')';" |
24 | | - "btnOut.disabled=zoom<=MIN+1e-6;btnIn.disabled=zoom>=MAX-1e-6;}" |
25 | | - "function getSvgSize(){const svg=stage.querySelector('svg');if(!svg)return{w:0,h:0};" |
26 | | - "const vb=svg.getAttribute('viewBox');if(vb){const p=vb.trim().split(/\s+/).map(Number);" |
27 | | - "if(p.length===4&&p[2]>0&&p[3]>0)return{w:p[2],h:p[3]};}" |
28 | | - "const w=parseFloat(svg.getAttribute('width'))||0;" |
29 | | - "const h=parseFloat(svg.getAttribute('height'))||0;" |
30 | | - "if(w&&h)return{w,h};const r=svg.getBoundingClientRect();return{w:r.width,h:r.height};}" |
31 | | - "function zoomIn(){zoom=Math.min(MAX,zoom+STEP);applyZoom();}" |
32 | | - "function zoomOut(){zoom=Math.max(MIN,zoom-STEP);applyZoom();}" |
33 | | - "function zoomReset(){zoom=1;applyZoom();}" |
34 | | - "function zoomFit(){const s=getSvgSize();if(!s.w||!s.h)return;" |
35 | | - "const availW=wrap.clientWidth-24;const availH=wrap.clientHeight-24;" |
36 | | - "const scaleW=availW/s.w;const scaleH=availH/s.h;" |
37 | | - "zoom=Math.max(MIN,Math.min(MAX,Math.min(scaleW,scaleH)));applyZoom();}" |
38 | | - "btnIn.addEventListener('click',zoomIn);btnOut.addEventListener('click',zoomOut);" |
39 | | - "btnReset.addEventListener('click',zoomReset);btnFit.addEventListener('click',zoomFit);" |
40 | | - "wrap.addEventListener('wheel',e=>{if(!(e.ctrlKey||e.metaKey))return;e.preventDefault();" |
41 | | - "const before=zoom;zoom=Math.min(MAX,Math.max(MIN,zoom+(e.deltaY<0?STEP:-STEP)));" |
42 | | - "if(zoom!==before)applyZoom();},{passive:false});" |
43 | | - "function fitWhenReady(a=0){const s=getSvgSize();if((s.w&&s.h)||a>10){zoomFit();}" |
44 | | - "else{requestAnimationFrame(()=>setTimeout(()=>fitWhenReady(a+1),16));}}" |
45 | | - "applyZoom();fitWhenReady();" |
46 | | - "let t=null;window.addEventListener('resize',()=>{clearTimeout(t);t=setTimeout(()=>zoomFit(),150);});" |
47 | | - "})();</script>" |
48 | | - ) |
| 5 | +from html import escape as _escape_html |
| 6 | +from typing import Optional, Dict |
| 7 | + |
| 8 | +from .html_theme import _dark_base_css # shared dark CSS |
| 9 | + |
49 | 10 |
|
50 | 11 | def write_workflow_html_inline(svg_text: str, html_path: str, project: str, workflow: str) -> None: |
51 | | - head = ( |
52 | | - "<!doctype html><html lang='en'><head>" |
53 | | - "<meta charset='utf-8'>" |
54 | | - "<meta name='viewport' content='width=device-width,initial-scale=1'>" |
55 | | - f"<title>{_esc(project)} / {_esc(workflow)}</title>" |
56 | | - f"<style>{workflow_page_css()}</style>" |
57 | | - "</head><body>" |
58 | | - ) |
59 | | - body = ( |
60 | | - "<header><div class='wrap hdr'>" |
61 | | - f"<h1>{_esc(project)} <span class='muted'>/</span> {_esc(workflow.replace('.dig',''))}</h1>" |
62 | | - "<div class='muted'>Workflow graph</div>" |
63 | | - "</div></header>" |
64 | | - "<main class='wrap'>" |
65 | | - "<div class='toolbar'>" |
66 | | - "<button class='btn' id='zoom-out'>−</button>" |
67 | | - "<button class='btn' id='zoom-in'>+</button>" |
68 | | - "<button class='btn' id='zoom-reset'>100%</button>" |
69 | | - "<button class='btn' id='zoom-fit'>Fit</button>" |
70 | | - "</div>" |
71 | | - "<div class='card stage'>" |
72 | | - "<div class='graph-wrap' id='graph-wrap'>" |
73 | | - f"<div id='svg-stage'>{svg_text}</div>" |
74 | | - "</div></div></main>" |
75 | | - f"<a class='btn-back' href='../../{SCHEDULE_INDEX_FILE}' title='Back to schedules'>← Back</a>" |
76 | | - f"{zoom_controls_script()}" |
77 | | - "</body></html>" |
| 12 | + """ |
| 13 | + Inline the SVG and add zoom controls + bigger layout with reliable Fit. |
| 14 | + """ |
| 15 | + DEFAULT_ZOOM_MIN = 0.25 |
| 16 | + DEFAULT_ZOOM_MAX = 3.0 |
| 17 | + DEFAULT_ZOOM_STEP = 0.1 |
| 18 | + |
| 19 | + def _workflow_page_css() -> str: |
| 20 | + return _dark_base_css() + """ |
| 21 | + /* wider page */ |
| 22 | + .wrap{max-width:100%; margin:0 auto; padding:16px 20px} |
| 23 | + .stage{padding:10px} |
| 24 | + .graph-wrap{height:90vh; overflow:auto; border:1px solid var(--border); |
| 25 | + border-radius:12px; background:#0f1117; position:relative} |
| 26 | + .toolbar{display:flex; gap:8px; align-items:center; justify-content:flex-end; |
| 27 | + padding:6px 0 10px 0; color:var(--muted)} |
| 28 | + .btn{background:#1f2937; border:1px solid #2c3342; color:var(--text); |
| 29 | + padding:6px 10px; border-radius:8px; cursor:pointer; font-size:12px} |
| 30 | + .btn:disabled{opacity:.5; cursor:default} |
| 31 | + #svg-stage{transform-origin:top left; width:max-content} |
| 32 | + #svg-stage svg{display:block} |
| 33 | + """ |
| 34 | + |
| 35 | + def _zoom_controls_script() -> str: |
| 36 | + return f""" |
| 37 | +<script> |
| 38 | +(function() {{ |
| 39 | + const wrap = document.getElementById('graph-wrap'); |
| 40 | + const stage = document.getElementById('svg-stage'); |
| 41 | + const btnIn = document.getElementById('zoom-in'); |
| 42 | + const btnOut = document.getElementById('zoom-out'); |
| 43 | + const btnReset = document.getElementById('zoom-reset'); |
| 44 | + const btnFit = document.getElementById('zoom-fit'); |
| 45 | +
|
| 46 | + let zoom = 1; |
| 47 | + const MIN = {DEFAULT_ZOOM_MIN}, MAX = {DEFAULT_ZOOM_MAX}, STEP = {DEFAULT_ZOOM_STEP}; |
| 48 | +
|
| 49 | + function applyZoom() {{ |
| 50 | + stage.style.transform = 'scale(' + zoom.toFixed(3) + ')'; |
| 51 | + btnOut.disabled = zoom <= MIN + 1e-6; |
| 52 | + btnIn.disabled = zoom >= MAX - 1e-6; |
| 53 | + }} |
| 54 | +
|
| 55 | + function getSvgSize() {{ |
| 56 | + const svg = stage.querySelector('svg'); |
| 57 | + if (!svg) return {{w:0, h:0}}; |
| 58 | + const vb = svg.getAttribute('viewBox'); |
| 59 | + if (vb) {{ |
| 60 | + const p = vb.trim().split(/\\s+/).map(Number); |
| 61 | + if (p.length === 4 && p[2] > 0 && p[3] > 0) return {{w:p[2], h:p[3]}}; |
| 62 | + }} |
| 63 | + const w = parseFloat(svg.getAttribute('width')) || 0; |
| 64 | + const h = parseFloat(svg.getAttribute('height')) || 0; |
| 65 | + if (w && h) return {{w, h}}; |
| 66 | + const r = svg.getBoundingClientRect(); |
| 67 | + return {{w: r.width, h: r.height}}; |
| 68 | + }} |
| 69 | +
|
| 70 | + function zoomIn() {{ zoom = Math.min(MAX, zoom + STEP); applyZoom(); }} |
| 71 | + function zoomOut() {{ zoom = Math.max(MIN, zoom - STEP); applyZoom(); }} |
| 72 | + function zoomReset() {{ zoom = 1; applyZoom(); }} |
| 73 | +
|
| 74 | + function zoomFit() {{ |
| 75 | + const s = getSvgSize(); |
| 76 | + if (!s.w || !s.h) return; |
| 77 | + const availW = wrap.clientWidth - 24; |
| 78 | + const availH = wrap.clientHeight - 24; |
| 79 | + const scaleW = availW / s.w; |
| 80 | + const scaleH = availH / s.h; |
| 81 | + zoom = Math.max(MIN, Math.min(MAX, Math.min(scaleW, scaleH))); |
| 82 | + applyZoom(); |
| 83 | + }} |
| 84 | +
|
| 85 | + btnIn.addEventListener('click', zoomIn); |
| 86 | + btnOut.addEventListener('click', zoomOut); |
| 87 | + btnReset.addEventListener('click', zoomReset); |
| 88 | + btnFit.addEventListener('click', zoomFit); |
| 89 | +
|
| 90 | + wrap.addEventListener('wheel', (e) => {{ |
| 91 | + if (!(e.ctrlKey || e.metaKey)) return; |
| 92 | + e.preventDefault(); |
| 93 | + const before = zoom; |
| 94 | + zoom = Math.min(MAX, Math.max(MIN, zoom + (e.deltaY < 0 ? STEP : -STEP))); |
| 95 | + if (zoom !== before) applyZoom(); |
| 96 | + }}, {{ passive: false }}); |
| 97 | +
|
| 98 | + function fitWhenReady(attempts=0) {{ |
| 99 | + const s = getSvgSize(); |
| 100 | + if ((s.w && s.h) || attempts > 10) {{ |
| 101 | + zoomFit(); |
| 102 | + }} else {{ |
| 103 | + requestAnimationFrame(() => setTimeout(() => fitWhenReady(attempts+1), 16)); |
| 104 | + }} |
| 105 | + }} |
| 106 | +
|
| 107 | + applyZoom(); |
| 108 | + fitWhenReady(); |
| 109 | +
|
| 110 | + let resizeTimer = null; |
| 111 | + window.addEventListener('resize', () => {{ |
| 112 | + clearTimeout(resizeTimer); |
| 113 | + resizeTimer = setTimeout(() => zoomFit(), 150); |
| 114 | + }}); |
| 115 | +}})(); |
| 116 | +</script> |
| 117 | +""" |
| 118 | + |
| 119 | + doc = f"""<!doctype html> |
| 120 | +<html lang="en"> |
| 121 | +<head> |
| 122 | + <meta charset="utf-8"> |
| 123 | + <meta name="viewport" content="width=device-width,initial-scale=1"> |
| 124 | + <title>{_escape_html(project)} · {_escape_html(workflow)}</title> |
| 125 | + <style>{_workflow_page_css()}</style> |
| 126 | +</head> |
| 127 | +<body> |
| 128 | +
|
| 129 | +<header> |
| 130 | + <div class="wrap hdr"> |
| 131 | + <h1>{_escape_html(project)} <span class="muted">/</span> {_escape_html(workflow.replace('.dig',''))}</h1> |
| 132 | + <div class="muted">Workflow graph</div> |
| 133 | + </div> |
| 134 | +</header> |
| 135 | +
|
| 136 | +<main class="wrap"> |
| 137 | + <div class="toolbar"> |
| 138 | + <button class="btn" id="zoom-out">−</button> |
| 139 | + <button class="btn" id="zoom-in">+</button> |
| 140 | + <button class="btn" id="zoom-reset">100%</button> |
| 141 | + <button class="btn" id="zoom-fit">Fit</button> |
| 142 | + </div> |
| 143 | + <div class="card stage"> |
| 144 | + <div class="graph-wrap" id="graph-wrap"> |
| 145 | + <div id="svg-stage">{svg_text}</div> |
| 146 | + </div> |
| 147 | + </div> |
| 148 | +</main> |
| 149 | +
|
| 150 | +<a class="btn-back" href="../../scheduled_workflows.html" title="Back to schedules">← Back</a> |
| 151 | +
|
| 152 | +{_zoom_controls_script()} |
| 153 | +
|
| 154 | +</body> |
| 155 | +</html>""" |
| 156 | + Path(html_path).write_text(doc, encoding="utf-8") |
| 157 | + |
| 158 | + |
| 159 | +def write_sql_page( |
| 160 | + project: str, |
| 161 | + querypath: str, |
| 162 | + sql_text: str, |
| 163 | + back_href: str, |
| 164 | + out_html_abs: Path, |
| 165 | + td_meta: Optional[Dict] = None, |
| 166 | + td_links: Optional[Dict[str, str]] = None, |
| 167 | +) -> None: |
| 168 | + """ |
| 169 | + Write a Prism-highlighted SQL page, with optional Treasure Data meta & console links. |
| 170 | +
|
| 171 | + Arguments match graph_generate.py's call-site. Older call styles that passed |
| 172 | + positional args will still work because we keep the order stable. |
| 173 | + """ |
| 174 | + td_meta = td_meta or {} |
| 175 | + td_links = td_links or {} |
| 176 | + |
| 177 | + # Build meta block (if any) |
| 178 | + meta_rows = [] |
| 179 | + for k in ("database", "engine", "priority", "retry", "timezone", "result_connection"): |
| 180 | + v = td_meta.get(k) |
| 181 | + if v is not None: |
| 182 | + meta_rows.append(f"<div><b>{_escape_html(k)}</b>: {_escape_html(str(v))}</div>") |
| 183 | + meta_html = ( |
| 184 | + f"<div class='card' style='padding:12px;margin-bottom:12px'>{''.join(meta_rows)}</div>" |
| 185 | + if meta_rows |
| 186 | + else "" |
78 | 187 | ) |
79 | | - Path(html_path).write_text(head + body, encoding="utf-8") |
80 | | - |
81 | | -def write_sql_page(project: str, querypath: str, src_sql: str, back_href_rel: str, out_html_abs: Path) -> None: |
82 | | - escaped_sql = _esc(src_sql) |
83 | | - doc = ( |
84 | | - "<!doctype html><html lang='en'><head>" |
85 | | - "<meta charset='utf-8'>" |
86 | | - "<meta name='viewport' content='width=device-width,initial-scale=1'>" |
87 | | - f"<title>{_esc(project)} / {_esc(Path(querypath).name)}</title>" |
88 | | - "<link rel='stylesheet' href='https://unpkg.com/prismjs/themes/prism-tomorrow.css'>" |
89 | | - f"<style>{dark_base_css()}" |
90 | | - "pre{white-space:pre;overflow:auto;max-height:75vh;padding:12px;border-radius:12px;" |
91 | | - "border:1px solid var(--border);background:#0f1117}" |
92 | | - ".meta{color:var(--muted);font-size:12px;margin-top:8px}" |
93 | | - "</style></head><body>" |
94 | | - "<header><div class='wrap'>" |
95 | | - f"<h1>{_esc(project)} <span class='muted'>/</span> {_esc(querypath)}</h1>" |
96 | | - "<div class='muted'>SQL source</div>" |
97 | | - "</div></header>" |
98 | | - "<main class='wrap'><div class='card' style='padding:16px 18px'>" |
99 | | - f"<pre><code class='language-sql'>{escaped_sql}</code></pre>" |
100 | | - "<div class='meta'>Generated by digdaggraph</div>" |
101 | | - "</div></main>" |
102 | | - f"<a class='btn-back' href='{_esca(back_href_rel)}' title='Back to workflow'>← Back</a>" |
103 | | - "<script src='https://unpkg.com/prismjs/components/prism-core.min.js'></script>" |
104 | | - "<script src='https://unpkg.com/prismjs/components/prism-clike.min.js'></script>" |
105 | | - "<script src='https://unpkg.com/prismjs/components/prism-sql.min.js'></script>" |
106 | | - "</body></html>" |
| 188 | + |
| 189 | + # Build console links (if any) |
| 190 | + links_html = "" |
| 191 | + if td_links: |
| 192 | + parts = [] |
| 193 | + for label, href in td_links.items(): |
| 194 | + parts.append( |
| 195 | + f"<a href='{_escape_html(href)}' target='_blank' rel='noopener'>{_escape_html(label)}</a>" |
| 196 | + ) |
| 197 | + links_html = " • ".join(parts) |
| 198 | + |
| 199 | + links_block = ( |
| 200 | + f"<div class='card' style='padding:12px;margin-bottom:12px'>{links_html}</div>" |
| 201 | + if links_html |
| 202 | + else "" |
107 | 203 | ) |
| 204 | + |
| 205 | + escaped_sql = _escape_html(sql_text) |
| 206 | + |
| 207 | + doc = f"""<!doctype html> |
| 208 | +<html lang="en"> |
| 209 | +<head> |
| 210 | + <meta charset="utf-8"> |
| 211 | + <meta name="viewport" content="width=device-width,initial-scale=1"> |
| 212 | + <title>{_escape_html(project)} · {_escape_html(querypath)}</title> |
| 213 | + <link rel="stylesheet" href="https://unpkg.com/prismjs/themes/prism-tomorrow.css"> |
| 214 | + <style>{_dark_base_css()} |
| 215 | + pre{{white-space:pre;overflow:auto;max-height:75vh;padding:12px;border-radius:12px; |
| 216 | + border:1px solid var(--border);background:#0f1117}} |
| 217 | + .meta{{color:var(--muted);font-size:12px;margin-top:8px}} |
| 218 | + </style> |
| 219 | +</head> |
| 220 | +<body> |
| 221 | +
|
| 222 | +<header> |
| 223 | + <div class="wrap"> |
| 224 | + <h1>{_escape_html(project)} <span class="muted">/</span> {_escape_html(querypath)}</h1> |
| 225 | + <div class="muted">SQL source</div> |
| 226 | + </div> |
| 227 | +</header> |
| 228 | +
|
| 229 | +<main class="wrap"> |
| 230 | + {links_block} |
| 231 | + {meta_html} |
| 232 | + <div class="card" style="padding:16px 18px"> |
| 233 | + <pre><code class="language-sql">{escaped_sql}</code></pre> |
| 234 | + <div class="meta">Generated by digdag-pages</div> |
| 235 | + </div> |
| 236 | +</main> |
| 237 | +
|
| 238 | +<a class="btn-back" href="{_escape_html(back_href)}" title="Back to workflow">← Back</a> |
| 239 | +
|
| 240 | +<script src="https://unpkg.com/prismjs/components/prism-core.min.js"></script> |
| 241 | +<script src="https://unpkg.com/prismjs/components/prism-clike.min.js"></script> |
| 242 | +<script src="https://unpkg.com/prismjs/components/prism-sql.min.js"></script> |
| 243 | +</body> |
| 244 | +</html>""" |
108 | 245 | out_html_abs.write_text(doc, encoding="utf-8") |
0 commit comments