README: add Quick run block, demo GIF, rationale and gallery; add terminal renderer script
This commit is contained in:
@@ -5,6 +5,36 @@
|
|||||||
|
|
||||||
Advanced analysis tool for identifying optimal Ceph OSD replacement candidates based on multiple health, capacity, resilience, and performance factors.
|
Advanced analysis tool for identifying optimal Ceph OSD replacement candidates based on multiple health, capacity, resilience, and performance factors.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ⚡ Quick run
|
||||||
|
|
||||||
|
Run on any Ceph node as root (read-only: it only queries `ceph`, `smartctl` and `lsblk`, locally and over SSH).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Most common: HDD OSDs, with debug output
|
||||||
|
sudo python3 -c "import urllib.request; exec(urllib.request.urlopen('https://code.lotusguild.org/LotusGuild/analyzeOSDs/raw/branch/main/ceph_osd_analyzer.py').read().decode())" --debug --class hdd
|
||||||
|
|
||||||
|
# All OSDs
|
||||||
|
sudo python3 -c "import urllib.request; exec(urllib.request.urlopen('https://code.lotusguild.org/LotusGuild/analyzeOSDs/raw/branch/main/ceph_osd_analyzer.py').read().decode())"
|
||||||
|
|
||||||
|
# Only NVMe, or only drives of at least 8 TB
|
||||||
|
sudo python3 -c "import urllib.request; exec(urllib.request.urlopen('https://code.lotusguild.org/LotusGuild/analyzeOSDs/raw/branch/main/ceph_osd_analyzer.py').read().decode())" --class nvme
|
||||||
|
sudo python3 -c "import urllib.request; exec(urllib.request.urlopen('https://code.lotusguild.org/LotusGuild/analyzeOSDs/raw/branch/main/ceph_osd_analyzer.py').read().decode())" --min-size 8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
With 30+ OSDs of mixed ages and sizes, "which drive do I replace next?" is a judgement call that involves SMART health, drive age, how full the OSD is, how much capacity the swap would add, and how balanced the hosts are. This tool gathers all of it from the live cluster and turns it into a **ranked list with the reasons spelled out**, so the next purchase and the next maintenance window are chosen on evidence.
|
||||||
|
|
||||||
|
## Gallery
|
||||||
|
|
||||||
|
Ranked HDD replacement candidates on the live cluster. Each candidate shows a replacement score, health score and issues, and the capacity and balance effect of replacing it, followed by a per-host summary. (Here osd.2 ranks first because no SMART data could be read for it.)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<sub>Rendered from real command output with `scripts/render_terminal.py`.</sub>
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This script provides comprehensive analysis of your Ceph cluster to identify which OSDs should be replaced first. It combines SMART health data, capacity optimization potential, cluster resilience impact, and performance metrics to generate a prioritized replacement list.
|
This script provides comprehensive analysis of your Ceph cluster to identify which OSDs should be replaced first. It combines SMART health data, capacity optimization potential, cluster resilience impact, and performance metrics to generate a prioritized replacement list.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 199 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