README: add Quick run block, demo GIF, rationale and gallery; add terminal renderer script
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / Shell (shellcheck) (push) Successful in 7s
This commit is contained in:
@@ -4,6 +4,34 @@
|
|||||||
|
|
||||||
A powerful server drive mapping tool that generates visual ASCII representations of server layouts and provides comprehensive drive information. Maps physical drive bays to logical Linux device names using PCI bus paths for reliable, persistent identification.
|
A powerful server drive mapping tool that generates visual ASCII representations of server layouts and provides comprehensive drive information. Maps physical drive bays to logical Linux device names using PCI bus paths for reliable, persistent identification.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ⚡ Quick run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run on any server that has a config below (as root)
|
||||||
|
bash <(curl -s https://code.lotusguild.org/LotusGuild/driveAtlas/raw/branch/main/driveAtlas.sh)
|
||||||
|
|
||||||
|
# Or with wget
|
||||||
|
bash <(wget -qO- https://code.lotusguild.org/LotusGuild/driveAtlas/raw/branch/main/driveAtlas.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
When a drive fails in a 10-bay case with no backplane and no per-bay LEDs, `/dev/sdX` letters tell you nothing about which physical drive to pull, and they change between boots. driveAtlas maps **physical bays to drives using stable PCI paths**, draws the chassis in ASCII, and lists every drive with its serial, SMART status, temperature and Ceph OSD, so you can verify the right drive by **serial number** before touching hardware. Each server has a small per-model configuration describing its layout.
|
||||||
|
|
||||||
|
## Gallery
|
||||||
|
|
||||||
|
A 4U Sliger case with ten hand-cabled bays: the front elevation, bay-to-device map and the sorted drive table with SMART health (a warning triangle flags drives that need attention, with the reason):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A second node with partly populated bays (empty bays show `--`):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<sub>Rendered from real command output with `scripts/render_terminal.py`; drive serials and private IPs are masked.</sub>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🗺️ **Visual ASCII art maps** showing physical drive bay layouts
|
- 🗺️ **Visual ASCII art maps** showing physical drive bay layouts
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 747 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -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))
|
||||||
Reference in New Issue
Block a user