Skip to content

Commit 5094821

Browse files
Updates
1 parent f0fe706 commit 5094821

File tree

1 file changed

+238
-101
lines changed

1 file changed

+238
-101
lines changed

src/digdaggraph/html_pages.py

Lines changed: 238 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,245 @@
1+
# src/digdaggraph/html_pages.py
2+
from __future__ import annotations
13

24
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+
4910

5011
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 ""
78187
)
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 ""
107203
)
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>"""
108245
out_html_abs.write_text(doc, encoding="utf-8")

0 commit comments

Comments
 (0)