README: add Quick run block, demo GIF, rationale and gallery; add terminal renderer script
Lint / Shell (shellcheck) (push) Successful in 7s

This commit is contained in:
2026-10-03 01:25:46 -04:00
parent e7d183f627
commit 6201bc54c8
4 changed files with 139 additions and 0 deletions
+35
View File
@@ -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)
<sub>Rendered from real command output with `scripts/render_terminal.py`; private IPs are masked.</sub>
## Features
- Complete system diagnostics and health checks
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+104
View File
@@ -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 <span> 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('</span>'*open_spans); open_spans=0
elif c==1: out.append('<span style="font-weight:700">'); open_spans+=1
elif c in ANSI_COL: out.append(f'<span style="color:{ANSI_COL[c]}">'); open_spans+=1
out.append(html.escape(line[pos:])); out.append('</span>'*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='''<!doctype html><meta charset=utf-8>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
body{margin:0;background:#0b0f14;font-family:'JetBrains Mono','DejaVu Sans Mono',monospace}
#win{width:%(w)dpx;margin:0;background:#0d1117;border:1px solid #1f2a37;border-radius:10px;overflow:hidden}
#bar{height:34px;background:#161b22;display:flex;align-items:center;padding:0 12px;gap:8px;border-bottom:1px solid #1f2a37}
.dot{width:12px;height:12px;border-radius:50%%}
#ttl{margin-left:10px;color:#8b949e;font-size:13px}
pre{margin:0;padding:16px 18px;color:#c9d1d9;font-size:14px;line-height:1.42;white-space:pre-wrap;min-height:%(h)dpx}
.p{color:#ff8c1a;font-weight:700}.c{color:#e6edf3}.cur{background:#c9d1d9;color:#0d1117}
</style>
<div id=win><div id=bar><span class=dot style=background:#ff5f56></span><span class=dot style=background:#ffbd2e></span><span class=dot style=background:#27c93f></span><span id=ttl>%(t)s</span></div><pre id=t></pre></div>'''
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'<span style="color:{col}">{s}</span>'
else:
s=re.sub('('+pat+')',lambda m:f'<span style="color:{col}">{m.group(0)}</span>',s)
return s
async def _render(title,command,lines,png,gif,width,rules,min_h):
rules=rules or DEFAULT_RULES
prompt='<span class=p>root@node</span><span style="color:#8b949e">:~#</span> '
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+('<span class=cur> </span>' if cursor else ''))
async def shot():
el=pg.locator('#win'); return await el.screenshot()
full=prompt+'<span class=c>'+html.escape(command)+'</span>\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+'<span class=c>'+html.escape(command[:i])+'</span>',True); add(await shot(),45)
await show(prompt+'<span class=c>'+html.escape(command)+'</span>',True); add(await shot(),500)
head=prompt+'<span class=c>'+html.escape(command)+'</span>\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))