diff --git a/README.md b/README.md index 57f5a9e..cd43cc1 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,41 @@ ProxDoc is a comprehensive diagnostic tool for Proxmox server health monitoring and system examination. +![ProxDoc quick check running on a Proxmox node](docs/img/demo.gif) + +## ⚡ Quick run + +Copy and paste on any Proxmox node (as root). Nothing is installed; the script is fetched and run. + +```bash +# Quick health check: services, temperatures, disk SMART, node exporter, Ceph +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --quick + +# Full examination (CPU, RAM, DIMMs, storage, network/NICs, HBAs, services, VMs, containers, Ceph) +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --diags + +# One area at a time +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --services # vital Proxmox services +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --drives # physical drive-bay map (DriveAtlas) +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --ceph # Ceph health, OSD tree, usage +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --vm-list # VM vitals +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --ct-list # container vitals +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --backup # backup health +curl -sL "https://code.lotusguild.org/LotusGuild/proxDoc/raw/branch/main/proxDoc.sh" | bash -s -- --checks=cpu,ram # pick specific checks +``` + +## Why this exists + +When something is wrong with one of six Proxmox nodes I want the same answer every time, in one command, without remembering which of a dozen tools to run: *are the services up, are the drives healthy, is Ceph happy, is it running hot?* ProxDoc is a single read-only bash script (it never changes system state) that runs those checks and prints a colour-coded report. It is meant to be run straight from the repository with `curl | bash`, so every node always runs the current version. + +## Gallery + +`--quick` on a live node (`compute-storage-01`): services, temperatures, SMART summary, node exporter and Ceph status. A Ceph warning (slow BlueStore operations, with the standing `noout` flag set) shows up in amber at the bottom. + +![proxDoc --quick](docs/img/quick-check.png) + +Rendered from real command output with `scripts/render_terminal.py`; private IPs are masked. + ## Features - Complete system diagnostics and health checks diff --git a/docs/img/demo.gif b/docs/img/demo.gif new file mode 100644 index 0000000..b0123f5 Binary files /dev/null and b/docs/img/demo.gif differ diff --git a/docs/img/quick-check.png b/docs/img/quick-check.png new file mode 100644 index 0000000..835feaa Binary files /dev/null and b/docs/img/quick-check.png differ diff --git a/scripts/render_terminal.py b/scripts/render_terminal.py new file mode 100644 index 0000000..8ef4d95 --- /dev/null +++ b/scripts/render_terminal.py @@ -0,0 +1,104 @@ +"""Render CLI output as a styled terminal screenshot / animated GIF with Playwright + Pillow. + +Usage (as a module): + from term_render import render + 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 +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'} +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(';')] + 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) + +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) + # 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) + 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=''' + + +
%(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}' + else: + 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 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 '')) + 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) + await show(full) + 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] + # all frames must share size: pad to the tallest + 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)) + 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))