diff --git a/scripts/render_terminal.py b/scripts/render_terminal.py index 8ef4d95..e8aa05e 100644 --- a/scripts/render_terminal.py +++ b/scripts/render_terminal.py @@ -5,44 +5,91 @@ Usage (as a module): render(title, command, lines, png='out.png', gif='out.gif', width=1000, rules=[(regex, css_color), ...]) `lines` is a list of plain-text output lines. No real terminal is needed, so output can be sanitized first. """ -import asyncio, html, io, re + +# flake8: noqa: E501 (the HTML/CSS page template below contains long literal lines) + +import asyncio +import html +import io +import re from PIL import Image from playwright.async_api import async_playwright +ANSI_COL = { + 30: "#6e7681", + 31: "#ff5c6c", + 32: "#3ddc84", + 33: "#ffb454", + 34: "#58a6ff", + 35: "#d2a8ff", + 36: "#7fdbff", + 37: "#e6edf3", + 90: "#8b949e", + 91: "#ff7b86", + 92: "#56f09f", + 93: "#ffd479", + 94: "#79c0ff", + 95: "#e2c5ff", + 96: "#a5f3ff", + 97: "#ffffff", +} + -ANSI_COL={30:'#6e7681',31:'#ff5c6c',32:'#3ddc84',33:'#ffb454',34:'#58a6ff',35:'#d2a8ff',36:'#7fdbff',37:'#e6edf3', - 90:'#8b949e',91:'#ff7b86',92:'#56f09f',93:'#ffd479',94:'#79c0ff',95:'#e2c5ff',96:'#a5f3ff',97:'#ffffff'} def ansi_to_html(line): """Convert ANSI SGR colour/bold sequences to tags (everything else is stripped).""" - out=[]; open_spans=0; pos=0 - for m in re.finditer(r'\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]',line): - out.append(html.escape(line[pos:m.start()])); pos=m.end() - if m.group(1) is None: continue - codes=[int(c) if c else 0 for c in m.group(1).split(';')] + out = [] + open_spans = 0 + pos = 0 + for m in re.finditer(r"\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]", line): + out.append(html.escape(line[pos : m.start()])) + pos = m.end() + if m.group(1) is None: + continue + codes = [int(c) if c else 0 for c in m.group(1).split(";")] for c in codes: - if c==0: out.append(''*open_spans); open_spans=0 - elif c==1: out.append(''); open_spans+=1 - elif c in ANSI_COL: out.append(f''); open_spans+=1 - out.append(html.escape(line[pos:])); out.append(''*open_spans) - return ''.join(out) + if c == 0: + out.append("" * open_spans) + open_spans = 0 + elif c == 1: + out.append('') + open_spans += 1 + elif c in ANSI_COL: + out.append(f'') + open_spans += 1 + out.append(html.escape(line[pos:])) + out.append("" * open_spans) + return "".join(out) + def mask(lines, serial_cols=True, ips=True): """Sanitize output before publishing: hide drive serials and private IPs.""" - res=[] - for l in lines: - if ips: l=re.sub(r'\b(10|192\.168|172\.(1[6-9]|2\d|3[01]))\.(\d+)\.(\d+)\.(\d+)\b','x.x.x.x',l) if not l.startswith('x') else l - if serial_cols and re.match(r'^(\d+|m2-\d)\s+/dev/',l): - parts=re.split(r'(\s{2,})',l) + res = [] + for ln in lines: + if ips: + ln = ( + re.sub(r"\b(10|192\.168|172\.(1[6-9]|2\d|3[01]))\.(\d+)\.(\d+)\.(\d+)\b", "x.x.x.x", ln) + if not ln.startswith("x") + else ln + ) + if serial_cols and re.match(r"^(\d+|m2-\d)\s+/dev/", ln): + parts = re.split(r"(\s{2,})", ln) # fields alternate with whitespace; serial is the 8th field (index 14 in the split list) - if len(parts)>14: - v=parts[14]; parts[14]=v[:2]+'\u2022'*max(0,len(v)-2) - l=''.join(parts) - res.append(l) + if len(parts) > 14: + v = parts[14] + parts[14] = v[:2] + "\u2022" * max(0, len(v) - 2) + ln = "".join(parts) + res.append(ln) return res -DEFAULT_RULES=[(r'✓|\bOK\b|\bNORMAL\b|\bPASS(ED)?\b|HEALTH_OK','#3ddc84'),(r'⚠️?|\bWARN(ING)?\b|HEALTH_WARN|\[WARN\]','#ffb454'),(r'\bERROR\b|\bFAIL(ED)?\b|\bCRITICAL\b|✗|HEALTH_ERR','#ff5c6c'),(r'^=+.*=+$|^┏.*|^┃.*|^┣.*|^┗.*','#7fdbff')] -PAGE=''' +DEFAULT_RULES = [ + (r"✓|\bOK\b|\bNORMAL\b|\bPASS(ED)?\b|HEALTH_OK", "#3ddc84"), + (r"⚠️?|\bWARN(ING)?\b|HEALTH_WARN|\[WARN\]", "#ffb454"), + (r"\bERROR\b|\bFAIL(ED)?\b|\bCRITICAL\b|✗|HEALTH_ERR", "#ff5c6c"), + (r"^=+.*=+$|^┏.*|^┃.*|^┣.*|^┗.*", "#7fdbff"), +] + +PAGE = """ -
%(t)s
''' +
%(t)s
""" + def colorize(line, rules): - if '\x1b[' in line: return ansi_to_html(line) - s=html.escape(line) - for pat,col in rules: - if pat.startswith('^'): - if re.search(pat,line): return f'{s}' + if "\x1b[" in line: + return ansi_to_html(line) + s = html.escape(line) + for pat, col in rules: + if pat.startswith("^"): + if re.search(pat, line): + return f'{s}' else: - s=re.sub('('+pat+')',lambda m:f'{m.group(0)}',s) + s = re.sub("(" + pat + ")", lambda m: f'{m.group(0)}', s) return s -async def _render(title,command,lines,png,gif,width,rules,min_h): - rules=rules or DEFAULT_RULES - prompt='root@node:~# ' + +async def _render(title, command, lines, png, gif, width, rules, min_h): + rules = rules or DEFAULT_RULES + prompt = 'root@node:~# ' async with async_playwright() as p: - b=await p.chromium.launch(); pg=await b.new_page(viewport={'width':width+2,'height':600}) - await pg.set_content(PAGE%{'w':width,'h':min_h,'t':html.escape(title)}); await pg.wait_for_timeout(1200) - async def show(body,cursor=False): - await pg.evaluate("(h)=>{document.getElementById('t').innerHTML=h}",body+(' ' if cursor else '')) + b = await p.chromium.launch() + pg = await b.new_page(viewport={"width": width + 2, "height": 600}) + await pg.set_content(PAGE % {"w": width, "h": min_h, "t": html.escape(title)}) + await pg.wait_for_timeout(1200) + + async def show(body, cursor=False): + await pg.evaluate( + "(h)=>{document.getElementById('t').innerHTML=h}", body + (" " if cursor else "") + ) + async def shot(): - el=pg.locator('#win'); return await el.screenshot() - full=prompt+''+html.escape(command)+'\n'+'\n'.join(colorize(l,rules) for l in lines) + el = pg.locator("#win") + return await el.screenshot() + + full = ( + prompt + + "" + + html.escape(command) + + "\n" + + "\n".join(colorize(ln, rules) for ln in lines) + ) await show(full) - if png: open(png,'wb').write(await shot()); print('png',png) + if png: + open(png, "wb").write(await shot()) + print("png", png) if gif: - frames=[]; dur=[] - def add(img,d): frames.append(Image.open(io.BytesIO(img)).convert('RGB')); dur.append(d) - await show(prompt,True); add(await shot(),500) - for i in range(1,len(command)+1,3): - await show(prompt+''+html.escape(command[:i])+'',True); add(await shot(),45) - await show(prompt+''+html.escape(command)+'',True); add(await shot(),500) - head=prompt+''+html.escape(command)+'\n'; out=[] - step=max(1,len(lines)//20) - for i in range(0,len(lines),step): - out=lines[:i+step]; await show(head+'\n'.join(colorize(l,rules) for l in out)); add(await shot(),120) - await show(full); add(await shot(),3500) - w0=frames[0].width - scale=min(1.0,780/w0); frames=[f.resize((int(f.width*scale),int(f.height*scale)),Image.LANCZOS) for f in frames] + frames = [] + dur = [] + + def add(img, d): + frames.append(Image.open(io.BytesIO(img)).convert("RGB")) + dur.append(d) + + await show(prompt, True) + add(await shot(), 500) + for i in range(1, len(command) + 1, 3): + await show(prompt + "" + html.escape(command[:i]) + "", True) + add(await shot(), 45) + await show(prompt + "" + html.escape(command) + "", True) + add(await shot(), 500) + head = prompt + "" + html.escape(command) + "\n" + out = [] + step = max(1, len(lines) // 20) + for i in range(0, len(lines), step): + out = lines[: i + step] + await show(head + "\n".join(colorize(ln, rules) for ln in out)) + add(await shot(), 120) + await show(full) + add(await shot(), 3500) + w0 = frames[0].width + scale = min(1.0, 780 / w0) + frames = [f.resize((int(f.width * scale), int(f.height * scale)), Image.LANCZOS) for f in frames] # all frames must share size: pad to the tallest - H=max(f.height for f in frames); W=frames[0].width - norm=[] + H = max(f.height for f in frames) + W = frames[0].width + norm = [] for f in frames: - c=Image.new('RGB',(W,H),(13,17,23)); c.paste(f,(0,0)); norm.append(c.quantize(colors=32,method=Image.MEDIANCUT,dither=Image.NONE)) - norm[0].save(gif,save_all=True,append_images=norm[1:],duration=dur,loop=0,optimize=True); print('gif',gif,len(norm)) + c = Image.new("RGB", (W, H), (13, 17, 23)) + c.paste(f, (0, 0)) + norm.append(c.quantize(colors=32, method=Image.MEDIANCUT, dither=Image.NONE)) + norm[0].save(gif, save_all=True, append_images=norm[1:], duration=dur, loop=0, optimize=True) + print("gif", gif, len(norm)) await b.close() -def render(title,command,lines,png=None,gif=None,width=1000,rules=None,min_h=200): - asyncio.run(_render(title,command,lines,png,gif,width,rules,min_h)) + +def render(title, command, lines, png=None, gif=None, width=1000, rules=None, min_h=200): + asyncio.run(_render(title, command, lines, png, gif, width, rules, min_h))