diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml
index ecb4021..e9860c9 100644
--- a/.gitea/workflows/lint.yml
+++ b/.gitea/workflows/lint.yml
@@ -17,7 +17,8 @@ jobs:
run: |
apt-get update -qq
apt-get install -y -qq python3 python3-pip
- pip3 install flake8
+ # Debian's Python is externally managed (PEP 668); the runner is ephemeral.
+ pip3 install --break-system-packages flake8
- name: Run flake8
run: flake8 . --exclude=__pycache__,.git
diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml
index f941f11..43a2133 100644
--- a/.gitea/workflows/security.yml
+++ b/.gitea/workflows/security.yml
@@ -19,7 +19,8 @@ jobs:
run: |
apt-get update -qq
apt-get install -y -qq python3 python3-pip
- pip3 install bandit
+ # Debian's Python is externally managed (PEP 668); the runner is ephemeral.
+ pip3 install --break-system-packages bandit
- name: Run bandit
run: bandit -r . --exclude .git,__pycache__ -ll
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 = """
-'''
+"""
+
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))