spb/maclustr-nodepages
Public
Python 99.8%
1#!/usr/bin/env python32"""MacLustr node pages — live metrics + maintenance schedule.3Stdlib only (Python 3.9+). Runs on m2u64, serves http://0.0.0.0:8003.4Each node label QR -> https://node.maclustr.io/<slug>."""5import json, os, subprocess, threading, time, html6from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer7from urllib.parse import urlparse89HERE = os.path.dirname(os.path.abspath(__file__))10PORT = 800311SELF_SLUG = "m2u64" # this server runs ON m2u64 -> collect locally12KEY = os.path.expanduser("~/.ssh/id_ed25519")13MAINT_FILE = os.path.join(HERE, "maintenance.json")1415NODES = json.load(open(os.path.join(HERE, "nodes.json")))16BY_SLUG = {n["slug"]: n for n in NODES}1718# recurring maintenance schedule (checklist) — (title, frequency, detail)19TASKS = [20 ("Dust / ventilation cleaned", "Quarterly",21 "Blow out vents & fans, clear dust from intakes and exhaust grilles."),22 ("macOS updated", "Monthly",23 "Install latest security + OS updates; reboot if the installer requires it."),24 ("Disk space checked (>15% free)", "Monthly",25 "Verify the boot volume keeps >15% free; purge caches/old logs if low."),26 ("Scheduled reboot performed", "Monthly",27 "Clean reboot to clear memory pressure and apply pending updates."),28 ("SSH / network connectivity tested", "Monthly",29 "Confirm SSH key login, ping, and tunnel / ngrok reachability."),30 ("Backup / Time Machine verified", "Weekly",31 "Confirm the last backup completed and a test file restores correctly."),32 ("Temperatures / throttling checked", "Quarterly",33 "Check pmset therm and Activity Monitor for sustained thermal throttling."),34 ("Cabling & power inspected", "Quarterly",35 "Inspect power / USB / Ethernet cables, UPS, and surge protection."),36 ("System logs reviewed (Console)", "Monthly",37 "Scan Console for kernel panics, repeated errors, hardware warnings."),38 ("External SSD health checked", "Quarterly",39 "Review SMART status and free space on attached external SSDs."),40]4142# dropdown vocabularies for the maintenance form43STATUS_OPTS = [44 ("", "— Set status —"),45 ("done", "✅ Done"),46 ("progress", "🔧 In progress"),47 ("scheduled", "📅 Scheduled"),48 ("skip", "⏭️ N/A · Skipped"),49 ("attention", "⚠️ Needs attention"),50 ("failed", "❌ Failed"),51]52STATUS_COLOR = {53 "done": "#16a34a", "progress": "#2563eb", "scheduled": "#7c3aed",54 "skip": "#9ca3af", "attention": "#d97706", "failed": "#dc2626", "": "transparent",55}56CONDITION_OPTS = [57 ("", "— Overall condition —"),58 ("excellent", "🟢 Excellent"),59 ("good", "🟢 Good"),60 ("fair", "🟡 Fair"),61 ("poor", "🟠 Poor — service soon"),62 ("critical", "🔴 Needs service now"),63]64BY_SUGGEST = ["SPB", "Remote", "On-site"] # datalist suggestions for "performed by"65STATUS_LABEL = {v: lbl for v, lbl in STATUS_OPTS}66CONDITION_LABEL = {v: lbl for v, lbl in CONDITION_OPTS}6768# ---- live metrics collector (runs on the target node) ----69COLLECT = r'''70PAGESIZE=$(sysctl -n hw.pagesize 2>/dev/null || echo 16384)71CPULINE=$(top -l 2 -n 0 2>/dev/null | grep "CPU usage" | tail -1)72USR=$(echo "$CPULINE" | sed -E 's/.*: ([0-9.]+)% user.*/\1/')73SYS=$(echo "$CPULINE" | sed -E 's/.*user, ([0-9.]+)% sys.*/\1/')74IDLE=$(echo "$CPULINE" | sed -E 's/.*[, ]([0-9.]+)% idle.*/\1/')75NCPU=$(sysctl -n hw.ncpu 2>/dev/null)76LOAD=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}')77MEMTOTAL=$(sysctl -n hw.memsize 2>/dev/null)78VM=$(vm_stat 2>/dev/null)79g(){ echo "$VM" | awk -F: "/$1/{gsub(/[^0-9]/,\"\",\$2); print \$2}"; }80ACTIVE=$(g "Pages active"); WIRED=$(g "Pages wired down"); COMP=$(g "Pages occupied by compressor")81FREE=$(g "Pages free"); SPEC=$(g "Pages speculative")82DF=$(df -k / 2>/dev/null | awk 'NR==2{print $2","$3","$4","$5}')83SWAPT=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*total = ([0-9.]+)M.*/\1/')84SWAPU=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*used = ([0-9.]+)M.*/\1/')85UP=$(uptime | sed -E 's/^.*up *//; s/, *[0-9]+ users?.*//; s/, *load.*//')86BOOT=$(sysctl -n kern.boottime 2>/dev/null | sed -E 's/.*sec = ([0-9]+).*/\1/')87NPROC=$(ps -A 2>/dev/null | wc -l | tr -d ' ')88TOP=$(ps -Aceo pcpu,comm -r 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}')89TOPMEM=$(ps -Aceo rss,comm -m 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}')90IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null)91THERM=$(pmset -g therm 2>/dev/null | sed -nE 's/.*CPU_Speed_Limit = ([0-9]+).*/\1/p')92BATT=$(pmset -g batt 2>/dev/null | grep -oE '[0-9]+%' | head -1)93OSV=$(sw_vers -productVersion 2>/dev/null)94BUILD=$(sw_vers -buildVersion 2>/dev/null)95printf "USR=%s\nSYS=%s\nIDLE=%s\nNCPU=%s\nLOAD=%s\nMEMTOTAL=%s\nPAGESIZE=%s\nACTIVE=%s\nWIRED=%s\nCOMP=%s\nFREE=%s\nSPEC=%s\nDF=%s\nSWAPT=%s\nSWAPU=%s\nUPTIME=%s\nBOOT=%s\nNPROC=%s\nTOP=%s\nTOPMEM=%s\nIP=%s\nTHERM=%s\nBATT=%s\nOSV=%s\nBUILD=%s\n" \96 "$USR" "$SYS" "$IDLE" "$NCPU" "$LOAD" "$MEMTOTAL" "$PAGESIZE" "$ACTIVE" "$WIRED" "$COMP" "$FREE" "$SPEC" "$DF" "$SWAPT" "$SWAPU" "$UP" "$BOOT" "$NPROC" "$TOP" "$TOPMEM" "$IP" "$THERM" "$BATT" "$OSV" "$BUILD"97'''9899_cache = {}100_cache_lock = threading.Lock()101TTL = 4102103def _run(slug, node):104 if slug == SELF_SLUG:105 cmd = ["bash", "-c", COLLECT]; inp = None106 else:107 cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no",108 "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=8",109 "-i", KEY, node["fqdn"], "bash -s"]; inp = COLLECT110 try:111 p = subprocess.run(cmd, input=inp, capture_output=True, text=True, timeout=20)112 if p.returncode != 0:113 return {"online": False, "error": (p.stderr or "ssh failed").strip()[:200]}114 kv = {}115 for line in p.stdout.splitlines():116 if "=" in line:117 k, v = line.split("=", 1); kv[k] = v118 return _parse(kv)119 except subprocess.TimeoutExpired:120 return {"online": False, "error": "timeout"}121 except Exception as e:122 return {"online": False, "error": str(e)[:200]}123124def _f(x, d=0.0):125 try: return float(x)126 except: return d127128def _procs(s):129 out = []130 for chunk in (s or "").split(";"):131 if "|" in chunk:132 a, b = chunk.split("|", 1); out.append((a, b))133 return out134135def _parse(kv):136 pg = _f(kv.get("PAGESIZE"), 16384)137 memtot = _f(kv.get("MEMTOTAL"))138 app = _f(kv.get("ACTIVE")) * pg139 wired = _f(kv.get("WIRED")) * pg140 comp = _f(kv.get("COMP")) * pg141 used = app + wired + comp142 idle = _f(kv.get("IDLE"))143 df = (kv.get("DF") or "0,0,0,0%").split(",")144 dtot = _f(df[0]) * 1024 if len(df) > 0 else 0145 dused = _f(df[1]) * 1024 if len(df) > 1 else 0146 dfree = _f(df[2]) * 1024 if len(df) > 2 else 0147 dpct = _f("".join(ch for ch in (df[3] if len(df) > 3 else "0") if ch.isdigit() or ch == "."))148 load = (kv.get("LOAD") or "").split()149 g = lambda b: round(b / 1e9, 1)150 topcpu = [{"v": a, "name": b} for a, b in _procs(kv.get("TOP"))][:5]151 topmem = [{"v": str(round(_f(a) / 1024)), "name": b} for a, b in _procs(kv.get("TOPMEM"))][:5]152 therm = kv.get("THERM", "")153 return {154 "online": True,155 "cpu_pct": round(max(0.0, min(100.0, 100.0 - idle)), 1),156 "cpu_user": round(_f(kv.get("USR")), 1),157 "cpu_sys": round(_f(kv.get("SYS")), 1),158 "ncpu": kv.get("NCPU", "?"),159 "mem_pct": round(used / memtot * 100, 1) if memtot else 0,160 "mem_used_gb": g(used), "mem_total_gb": g(memtot),161 "mem_app_gb": g(app), "mem_wired_gb": g(wired), "mem_comp_gb": g(comp),162 "disk_pct": round(dpct, 1),163 "disk_used_gb": g(dused), "disk_free_gb": g(dfree), "disk_total_gb": g(dtot),164 "load": load[:3],165 "swap_used": kv.get("SWAPU", "0"), "swap_total": kv.get("SWAPT", "0"),166 "uptime": (kv.get("UPTIME") or "?").strip(),167 "nproc": kv.get("NPROC", "?"),168 "osv": kv.get("OSV", "?"), "build": kv.get("BUILD", "?"),169 "ip": kv.get("IP", ""),170 "batt": kv.get("BATT", ""),171 "thermal": "Throttled (" + therm + "%)" if (therm and therm != "100") else ("Nominal" if therm else ""),172 "topcpu": topcpu, "topmem": topmem,173 "ts": int(time.time()),174 }175176def live(slug):177 node = BY_SLUG[slug]; now = time.time()178 with _cache_lock:179 c = _cache.get(slug)180 if c and now - c[0] < TTL:181 return c[1]182 data = _run(slug, node)183 with _cache_lock:184 _cache[slug] = (now, data)185 return data186187# ---- maintenance persistence ----188_maint_lock = threading.Lock()189def load_maint():190 try: return json.load(open(MAINT_FILE))191 except: return {}192def save_maint(d):193 with _maint_lock:194 json.dump(d, open(MAINT_FILE, "w"), indent=2)195def get_maint(slug):196 return load_maint().get(slug, {"tasks": {}, "next": "", "log": "",197 "condition": "", "inspected_by": "", "reports": []})198199def _build_report(payload, when):200 """Snapshot of what was done at save time -> a dated maintenance report."""201 tasks = payload.get("tasks", {}) or {}202 items = []203 for i, (title, fr, det) in enumerate(TASKS):204 t = tasks.get(str(i)) or tasks.get(i) or {}205 status = t.get("status") or ("done" if t.get("done") else "")206 if status or t.get("note") or t.get("date"):207 items.append({"title": title, "status": status,208 "date": t.get("date", ""), "by": t.get("by", ""),209 "note": t.get("note", "")})210 return {"at": when, "by": payload.get("inspected_by", ""),211 "condition": payload.get("condition", ""),212 "next": payload.get("next", ""), "log": payload.get("log", ""),213 "items": items}214215def set_maint(slug, payload):216 d = load_maint()217 now = time.strftime("%Y-%m-%d %H:%M")218 prev = d.get(slug, {})219 reports = list(prev.get("reports", []))220 reports.insert(0, _build_report(payload, now)) # newest first221 reports = reports[:60] # keep last 60 reports222 d[slug] = {"tasks": payload.get("tasks", {}), "next": payload.get("next", ""),223 "log": payload.get("log", ""),224 "condition": payload.get("condition", ""),225 "inspected_by": payload.get("inspected_by", ""),226 "reports": reports,227 "saved_at": now}228 save_maint(d); return d[slug]229230def esc(s): return html.escape(str(s))231232CSS = """233:root{--bg:#f2f3f7;--card:#fff;--ink:#1d1d1f;--mut:#6b7280;--line:#e6e8ef;--acc:#7c3aed;234--green:#16a34a;--amber:#d97706;--red:#dc2626;--shadow:0 1px 3px rgba(16,24,40,.06),0 1px 2px rgba(16,24,40,.04)}235*{box-sizing:border-box;margin:0;padding:0}236html{-webkit-text-size-adjust:100%}237body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5;-webkit-font-smoothing:antialiased}238a{color:var(--acc);text-decoration:none}239.wrap{max-width:1080px;margin:0 auto;padding:18px 16px 64px}240.bar-top{display:flex;align-items:center;gap:12px;margin-bottom:18px}241.bar-top .logo{width:42px;height:42px;border-radius:11px;box-shadow:var(--shadow)}242.bar-top .brand{font-weight:800;letter-spacing:.4px;font-size:13px;color:var(--mut)}243.bar-top .back{margin-left:auto;font-size:13px;font-weight:600;color:var(--mut)}244.hero{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:var(--shadow);245display:flex;align-items:center;gap:16px;flex-wrap:wrap}246.hero h1{font-size:clamp(34px,9vw,58px);font-weight:850;letter-spacing:-1.5px;line-height:1}247.hero .meta{color:var(--mut);font-size:14px;margin-top:6px}248.badge{padding:7px 16px;border-radius:10px;color:#fff;font-weight:800;font-size:16px;margin-left:auto}249.chiptag{display:inline-block;background:#111;color:#fff;font-weight:800;font-size:13px;padding:4px 12px;border-radius:8px;margin-top:8px}250.status{display:flex;align-items:center;gap:8px;margin:26px 2px 12px;font-size:13px;color:var(--mut)}251.dot{width:10px;height:10px;border-radius:50%;background:#9ca3af;display:inline-block}252.dot.on{background:var(--green);box-shadow:0 0 0 4px rgba(22,163,74,.15)}253.dot.off{background:var(--red);box-shadow:0 0 0 4px rgba(220,38,38,.15)}254h2{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--mut);margin:30px 4px 12px}255.grid{display:grid;gap:12px}256.g-metric{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}257.g-spec{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}258.g-two{grid-template-columns:1fr 1fr}259@media(max-width:560px){.g-two{grid-template-columns:1fr}}260.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 16px;box-shadow:var(--shadow)}261.k{font-size:11px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)}262.v{font-size:30px;font-weight:850;margin-top:4px;font-variant-numeric:tabular-nums}263.v small{font-size:14px;color:var(--mut);font-weight:700}264.sub{color:var(--mut);font-size:12.5px;margin-top:3px}265.bar{height:8px;border-radius:5px;background:#eceef4;overflow:hidden;margin-top:10px}266.bar>i{display:block;height:100%;border-radius:5px;width:0;transition:width .6s ease}267.row{display:flex;justify-content:space-between;font-size:13.5px;padding:7px 0;border-bottom:1px solid var(--line)}268.row:last-child{border-bottom:0}269.row b{font-weight:700}270.proc{display:flex;justify-content:space-between;font-size:13px;padding:5px 0;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}271.proc:last-child{border-bottom:0}272.proc .nm{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:72%}273.proc .pc{color:var(--mut);font-weight:700}274.mwrap{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}275.mhead{display:grid;grid-template-columns:150px 1fr 140px 130px 1.1fr;gap:12px;padding:11px 16px;border-bottom:1px solid var(--line);276background:#fafafe;font-size:10.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)}277.mrow{display:grid;grid-template-columns:150px 1fr 140px 130px 1.1fr;gap:12px;align-items:start;padding:12px 16px;border-bottom:1px solid var(--line);border-left:4px solid transparent;transition:border-left-color .25s}278.mrow:last-child{border-bottom:0}279.mrow .t{font-weight:600;font-size:14.5px}280.mrow .freq{display:inline-block;font-size:10px;color:var(--acc);font-weight:800;text-transform:uppercase;letter-spacing:.4px;background:rgba(124,58,237,.09);padding:2px 7px;border-radius:6px;margin-top:4px}281.mrow .det{font-size:12px;color:var(--mut);margin-top:5px;line-height:1.4}282@media(max-width:620px){.mhead{display:none}.mrow{grid-template-columns:1fr;row-gap:9px}}283input[type=checkbox]{width:22px;height:22px;accent-color:var(--green);cursor:pointer}284input[type=date],input[type=text],textarea,select{background:#fbfbfd;border:1px solid #d8dbe6;color:var(--ink);border-radius:9px;padding:9px 10px;font-size:14px;width:100%;font-family:inherit}285input:focus,textarea:focus,select:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,58,237,.12)}286textarea{min-height:80px;resize:vertical}287select{-webkit-appearance:none;appearance:none;cursor:pointer;padding-right:30px;288background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%236b7280' stroke-width='1.8' stroke-linecap='round'/></svg>");289background-repeat:no-repeat;background-position:right 11px center}290.savebar{display:flex;align-items:center;gap:14px;margin-top:16px;flex-wrap:wrap}291.btn{background:var(--acc);color:#fff;border:0;border-radius:11px;padding:13px 26px;font-weight:800;font-size:15px;cursor:pointer;box-shadow:var(--shadow)}292.btn:active{transform:translateY(1px)}293.saved{color:var(--green);font-weight:700;opacity:0;transition:opacity .3s}294.muted{color:var(--mut);font-size:13px}295.rcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;box-shadow:var(--shadow);margin-bottom:12px}296.rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding-bottom:10px;margin-bottom:10px;border-bottom:1px solid var(--line)}297.rhead b{font-size:15px;font-variant-numeric:tabular-nums}298.rby{font-size:12.5px;color:var(--mut);font-weight:700}299.rcond{margin-left:auto;font-size:12.5px;font-weight:800;background:#f4f4fb;border:1px solid var(--line);padding:3px 10px;border-radius:8px}300.ritem{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:7px 0;border-bottom:1px dashed var(--line)}301.ritem:last-child{border-bottom:0}302.ritem .rt{font-size:13.5px;font-weight:600}303.ritem .rd{font-size:12px;color:var(--mut);font-variant-numeric:tabular-nums}304.ritem .rb{font-size:12px;color:var(--mut);font-weight:700}305.ritem .rn{flex-basis:100%;font-size:12.5px;color:var(--mut);padding-left:2px}306.schip{font-size:11px;font-weight:800;padding:2px 9px;border-radius:7px;white-space:nowrap}307.rlog{margin-top:10px;font-size:13px;color:var(--ink);background:#fafafe;border:1px solid var(--line);border-radius:9px;padding:9px 11px;white-space:pre-wrap}308.rnext{margin-top:8px;font-size:12.5px;color:var(--acc);font-weight:700}309.foot{margin-top:34px;color:var(--mut);font-size:12px;text-align:center}310"""311312def node_page(n):313 slug = n["slug"]; tc = n["tcolor"]314 storage = f'{n["internal"]} SSD' + (f' + {n["external"]} external' if n["external"] else "")315 status_opts = "".join(f'<option value="{esc(v)}">{esc(lbl)}</option>' for v, lbl in STATUS_OPTS)316 rows = "".join(317 f'<div class="mrow">'318 f'<select data-i="{i}" class="mstatus" onchange="colorRow(this)">{status_opts}</select>'319 f'<div><div class="t">{esc(t)}</div><span class="freq">{esc(fr)}</span>'320 f'<div class="det">{esc(det)}</div></div>'321 f'<input type="date" data-i="{i}" class="md mdate">'322 f'<input type="text" data-i="{i}" class="mby" list="bylist" placeholder="performed by">'323 f'<input type="text" data-i="{i}" class="mn mnote" placeholder="note">'324 f'</div>'325 for i, (t, fr, det) in enumerate(TASKS))326 cond_opts = "".join(f'<option value="{esc(v)}">{esc(lbl)}</option>' for v, lbl in CONDITION_OPTS)327 by_datalist = "".join(f'<option value="{esc(b)}">' for b in BY_SUGGEST)328 return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">329<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">330<meta name="theme-color" content="#f2f3f7">331<title>{esc(n['alias'])} · MacLustr</title><style>{CSS}</style></head><body><div class="wrap">332333<div class="bar-top"><img class="logo" src="/logo.svg" alt="">334<span class="brand">MACLUSTR · COMPUTE CLUSTER</span>335<a class="back" href="/">All nodes →</a></div>336337<div class="hero">338 <div>339 <h1>{esc(n['alias'])}</h1>340 <div class="meta">{esc(n['fqdn'])} · {esc(n['model'])} ({esc(n['modelid'])})</div>341 <span class="chiptag">{esc(n['chip'])}</span>342 </div>343 <span class="badge" style="background:{tc}">{esc(n['tier'])}</span>344</div>345346<div class="status"><span id="dot" class="dot"></span><span id="stattxt">Connecting…</span></div>347<div class="grid g-metric">348 <div class="card"><div class="k">CPU Load</div><div class="v"><span id="cpu">–</span><small>%</small></div>349 <div class="bar"><i id="cpubar" style="background:var(--acc)"></i></div>350 <div class="sub"><span id="cpuus">–</span>% user · <span id="cpusy">–</span>% sys · <span id="ncpu">–</span> cores</div></div>351 <div class="card"><div class="k">Memory</div><div class="v"><span id="mem">–</span><small>%</small></div>352 <div class="bar"><i id="membar" style="background:#2563eb"></i></div>353 <div class="sub"><span id="memu">–</span> / <span id="memt">–</span> GB used</div></div>354 <div class="card"><div class="k">Disk /</div><div class="v"><span id="disk">–</span><small>%</small></div>355 <div class="bar"><i id="diskbar" style="background:#0891b2"></i></div>356 <div class="sub"><span id="dfree">–</span> GB free of <span id="dtot">–</span> GB</div></div>357 <div class="card"><div class="k">Load Average</div><div class="v" style="font-size:24px" id="load">–</div>358 <div class="sub">1 / 5 / 15 min</div></div>359 <div class="card"><div class="k">Uptime</div><div class="v" style="font-size:20px" id="uptime">–</div>360 <div class="sub">processes: <span id="nproc">–</span></div></div>361 <div class="card"><div class="k">Health</div><div class="v" style="font-size:20px" id="health">–</div>362 <div class="sub">swap <span id="swap">–</span> · <span id="ipv">–</span></div></div>363</div>364365<div class="grid g-two" style="margin-top:12px">366 <div class="card"><div class="k">Top processes — CPU</div><div id="topcpu" style="margin-top:8px"></div></div>367 <div class="card"><div class="k">Top processes — Memory</div><div id="topmem" style="margin-top:8px"></div></div>368</div>369370<h2>Hardware specifications (capacity)</h2>371<div class="grid g-spec">372 <div class="card"><div class="k">CPU</div><div class="v">{n['ncpu']}<small> cores</small></div><div class="sub">{n['nperf']} performance + {n['neff']} efficiency</div></div>373 <div class="card"><div class="k">Memory</div><div class="v">{n['mem']}<small> GB</small></div><div class="sub">unified · {esc(n['bw'])}</div></div>374 <div class="card"><div class="k">GPU</div><div class="v">{n['gpu']}<small> cores</small></div></div>375 <div class="card"><div class="k">Neural Engine</div><div class="v">{n['ne']}<small> cores</small></div></div>376 <div class="card"><div class="k">Storage</div><div class="v" style="font-size:19px">{esc(storage)}</div></div>377 <div class="card"><div class="k">Tier · Role</div><div class="v" style="font-size:19px">{esc(n['tier'])} · {esc(n['role'])}</div></div>378</div>379380<h2>System</h2>381<div class="card">382 <div class="row"><span>macOS</span><b><span id="s_os">{esc(n['osv'])}</span> (<span id="s_build">{esc(n['build'])}</span>)</b></div>383 <div class="row"><span>Model identifier</span><b>{esc(n['modelid'])}</b></div>384 <div class="row"><span>Serial number</span><b>{esc(n['serial'])}</b></div>385 <div class="row"><span>Local IP</span><b id="s_ip">–</b></div>386 <div class="row"><span>Hostname</span><b>{esc(n['fqdn'])}</b></div>387</div>388389<h2>Maintenance schedule</h2>390<p class="muted" style="margin:0 4px 12px">Set a status from the dropdown, the date it was done, who performed it, and a note. Saved on m2u64 — shared with everyone who opens this page.</p>391<datalist id="bylist">{by_datalist}</datalist>392<div class="mwrap">393 <div class="mhead"><span>Status</span><span>Task</span><span>Date</span><span>By</span><span>Note</span></div>394 {rows}395</div>396<div class="grid g-spec" style="margin-top:12px">397 <div class="card"><div class="k">Overall condition</div><select id="cond" style="margin-top:9px">{cond_opts}</select></div>398 <div class="card"><div class="k">Inspected by</div><input type="text" id="by_all" list="bylist" placeholder="name / initials" style="margin-top:9px"></div>399 <div class="card"><div class="k">Next maintenance due</div><input type="date" id="next" style="margin-top:9px"></div>400</div>401<div class="card" style="margin-top:12px"><div class="k">Log / notes</div><textarea id="log" placeholder="Notes, incidents, parts replaced…" style="margin-top:9px"></textarea></div>402<div class="savebar"><button class="btn" onclick="saveM()">Submit maintenance report</button>403 <span class="saved" id="saved">Saved ✓</span><span class="muted" id="savedat"></span></div>404405<h2>Maintenance reports <span id="repcount" class="muted" style="font-weight:700;text-transform:none;letter-spacing:0"></span></h2>406<p class="muted" style="margin:0 4px 12px">Every time you submit, a dated report of what was done is added here.</p>407<div id="reports"></div>408409<div class="foot">MacLustr node page · auto-refreshes every 5s · <a href="/">cluster overview</a></div>410</div>411<script>412const SLUG="{slug}";413const $=id=>document.getElementById(id);414function bar(id,p){{const e=$(id);e.style.width=Math.min(100,p)+'%';415 e.style.background=p>90?'var(--red)':p>75?'var(--amber)':e.dataset.c||e.style.background;416 if(!e.dataset.c)e.dataset.c=e.style.background;}}417function plist(el,arr,unit){{el.innerHTML=(arr||[]).map(p=>418 `<div class="proc"><span class="nm">${{p.name}}</span><span class="pc">${{p.v}}${{unit}}</span></div>`).join('')||'<div class="muted">—</div>';}}419async function refresh(){{420 try{{const d=await (await fetch('/api/'+SLUG+'/live')).json();421 if(!d.online){{$('dot').className='dot off';$('stattxt').textContent='Offline — '+(d.error||'no response');return;}}422 $('dot').className='dot on';423 $('cpu').textContent=d.cpu_pct;bar('cpubar',d.cpu_pct);$('cpuus').textContent=d.cpu_user;$('cpusy').textContent=d.cpu_sys;$('ncpu').textContent=d.ncpu;424 $('mem').textContent=d.mem_pct;bar('membar',d.mem_pct);$('memu').textContent=d.mem_used_gb;$('memt').textContent=d.mem_total_gb;425 $('disk').textContent=d.disk_pct;bar('diskbar',d.disk_pct);$('dfree').textContent=d.disk_free_gb;$('dtot').textContent=d.disk_total_gb;426 $('load').textContent=(d.load||[]).join(' ');427 $('uptime').textContent=d.uptime;$('nproc').textContent=d.nproc;428 $('health').textContent=d.thermal||'OK';429 $('swap').textContent=d.swap_used+'M';$('ipv').textContent=d.ip||'';430 $('s_ip').textContent=d.ip||'–';$('s_os').textContent=d.osv;$('s_build').textContent=d.build;431 plist($('topcpu'),d.topcpu,'%');plist($('topmem'),d.topmem,' MB');432 $('stattxt').textContent='Live · updated '+new Date(d.ts*1000).toLocaleTimeString();433 }}catch(e){{$('dot').className='dot off';$('stattxt').textContent='Connection error';}}434}}435const SCOLOR={json.dumps(STATUS_COLOR)};436const SLABEL={json.dumps(STATUS_LABEL)};437const CLABEL={json.dumps(CONDITION_LABEL)};438function esc(s){{return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({{'&':'&','<':'<','>':'>','"':'"'}}[c]));}}439function colorRow(sel){{const r=sel.closest('.mrow');if(r)r.style.borderLeftColor=SCOLOR[sel.value]||'transparent';}}440function statusChip(s){{const c=SCOLOR[s]||'#9ca3af';const l=SLABEL[s]||'—';441 return `<span class="schip" style="background:${{c}}1f;color:${{c}};border:1px solid ${{c}}55">${{esc(l)}}</span>`;}}442function renderReports(reps){{const el=$('reports'),cn=$('repcount');reps=reps||[];443 cn.textContent=reps.length?`· ${{reps.length}} on record`:'';444 if(!reps.length){{el.innerHTML='<p class="muted" style="margin:4px">No maintenance reports yet — submit one above.</p>';return;}}445 el.innerHTML=reps.map(r=>{{446 const items=(r.items||[]).map(it=>447 `<div class="ritem">${{statusChip(it.status)}}<span class="rt">${{esc(it.title)}}</span>`+448 (it.date?`<span class="rd">${{esc(it.date)}}</span>`:'')+449 (it.by?`<span class="rb">· ${{esc(it.by)}}</span>`:'')+450 (it.note?`<div class="rn">“${{esc(it.note)}}”</div>`:'')+`</div>`451 ).join('')||'<div class="muted" style="padding:6px 0">No tasks were marked in this report.</div>';452 const cond=r.condition?`<span class="rcond">${{esc(CLABEL[r.condition]||r.condition)}}</span>`:'';453 return `<div class="rcard"><div class="rhead"><b>${{esc(r.at)}}</b>`+454 (r.by?`<span class="rby">by ${{esc(r.by)}}</span>`:'')+cond+`</div>`+items+455 (r.log?`<div class="rlog">${{esc(r.log)}}</div>`:'')+456 (r.next?`<div class="rnext">Next maintenance due: ${{esc(r.next)}}</div>`:'')+`</div>`;457 }}).join('');}}458async function loadM(){{const d=await (await fetch('/api/'+SLUG+'/maintenance')).json();const t=d.tasks||{{}};459 document.querySelectorAll('.mstatus').forEach(c=>{{const v=t[c.dataset.i]||{{}};460 c.value=v.status||(v.done?'done':'');colorRow(c);}});461 document.querySelectorAll('.mdate').forEach(c=>c.value=(t[c.dataset.i]||{{}}).date||'');462 document.querySelectorAll('.mby').forEach(c=>c.value=(t[c.dataset.i]||{{}}).by||'');463 document.querySelectorAll('.mnote').forEach(c=>c.value=(t[c.dataset.i]||{{}}).note||'');464 $('next').value=d.next||'';$('log').value=d.log||'';465 $('cond').value=d.condition||'';$('by_all').value=d.inspected_by||'';466 renderReports(d.reports);467 if(d.saved_at)$('savedat').textContent='last saved '+d.saved_at;}}468async function saveM(){{const tasks={{}};469 document.querySelectorAll('.mstatus').forEach(c=>tasks[c.dataset.i]={{status:c.value,done:c.value==='done'}});470 document.querySelectorAll('.mdate').forEach(c=>tasks[c.dataset.i].date=c.value);471 document.querySelectorAll('.mby').forEach(c=>tasks[c.dataset.i].by=c.value);472 document.querySelectorAll('.mnote').forEach(c=>tasks[c.dataset.i].note=c.value);473 const d=await (await fetch('/api/'+SLUG+'/maintenance',{{method:'POST',headers:{{'Content-Type':'application/json'}},474 body:JSON.stringify({{tasks,next:$('next').value,log:$('log').value,475 condition:$('cond').value,inspected_by:$('by_all').value}})}})).json();476 renderReports(d.reports);477 const s=$('saved');s.style.opacity=1;setTimeout(()=>s.style.opacity=0,1800);478 if(d.saved_at)$('savedat').textContent='last saved '+d.saved_at;}}479refresh();loadM();setInterval(refresh,5000);480</script></body></html>"""481482def index_page():483 tc = sum(n["ncpu"] for n in NODES); tg = sum(n["gpu"] for n in NODES); tr = sum(n["mem"] for n in NODES)484 cards = "".join(485 f'<a class="card" style="display:block;color:inherit" href="/{n["slug"]}">'486 f'<div style="display:flex;align-items:center;gap:8px"><span class="dot" id="d_{n["slug"]}"></span>'487 f'<b style="font-size:19px">{esc(n["alias"])}</b>'488 f'<span class="badge" style="background:{n["tcolor"]};font-size:11px;padding:3px 9px;margin-left:auto">{esc(n["tier"])}</span></div>'489 f'<div class="sub" style="margin-top:7px">{esc(n["chip"])}</div>'490 f'<div class="sub">{n["ncpu"]} cores · {n["mem"]} GB · {n["gpu"]} GPU</div>'491 f'<div class="sub" id="s_{n["slug"]}" style="margin-top:7px;font-variant-numeric:tabular-nums">CPU – · MEM –</div>'492 f'</a>'493 for n in NODES)494 return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">495<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">496<meta name="theme-color" content="#f2f3f7"><title>MacLustr · Cluster</title><style>{CSS}</style></head><body><div class="wrap">497<div class="bar-top"><img class="logo" src="/logo.svg"><span class="brand">MACLUSTR</span></div>498<div class="hero"><div><h1 style="font-size:42px">Cluster</h1>499<div class="meta">{len(NODES)} nodes · {tc} CPU cores · {tr} GB RAM · {tg} GPU cores</div></div></div>500<h2>Nodes</h2>501<div class="grid g-spec">{cards}</div>502<div class="foot">Scan a label QR to open a node's live page.</div>503</div><script>504const S={json.dumps([n["slug"] for n in NODES])},$=id=>document.getElementById(id);505async function poll(s){{try{{const d=await (await fetch('/api/'+s+'/live')).json();506 const dot=$('d_'+s),t=$('s_'+s);507 if(d.online){{dot.className='dot on';t.textContent='CPU '+d.cpu_pct+'% · MEM '+d.mem_pct+'%';}}508 else{{dot.className='dot off';t.textContent='offline';}}}}catch(e){{}}}}509function all(){{S.forEach((s,i)=>setTimeout(()=>poll(s),i*100));}}510all();setInterval(all,8000);511</script></body></html>"""512513LOGO_PATH = os.path.join(HERE, "logo.svg")514LOGO = open(LOGO_PATH).read() if os.path.exists(LOGO_PATH) else "<svg/>"515516class H(BaseHTTPRequestHandler):517 def log_message(self, *a): pass518 def _send(self, code, body, ctype="text/html; charset=utf-8"):519 b = body.encode() if isinstance(body, str) else body520 self.send_response(code)521 self.send_header("Content-Type", ctype)522 self.send_header("Content-Length", str(len(b)))523 self.send_header("Cache-Control", "no-store")524 self.end_headers()525 try: self.wfile.write(b)526 except (BrokenPipeError, ConnectionResetError): pass527 def do_GET(self):528 path = urlparse(self.path).path.strip("/")529 if path == "": return self._send(200, index_page())530 if path in ("health", "healthz"): return self._send(200, "ok", "text/plain")531 if path == "logo.svg": return self._send(200, LOGO, "image/svg+xml")532 if path.startswith("api/"):533 parts = path.split("/")534 if len(parts) == 3 and parts[1].lower() in BY_SLUG:535 slug = parts[1].lower()536 if parts[2] == "live": return self._send(200, json.dumps(live(slug)), "application/json")537 if parts[2] == "maintenance": return self._send(200, json.dumps(get_maint(slug)), "application/json")538 return self._send(404, "{}", "application/json")539 if path.lower() in BY_SLUG: return self._send(200, node_page(BY_SLUG[path.lower()]))540 return self._send(404, "<h1>404</h1><p><a href='/'>cluster</a></p>")541 def do_POST(self):542 parts = urlparse(self.path).path.strip("/").split("/")543 if len(parts) == 3 and parts[0] == "api" and parts[2] == "maintenance" and parts[1].lower() in BY_SLUG:544 try:545 n = int(self.headers.get("Content-Length", 0))546 payload = json.loads(self.rfile.read(n) or b"{}")547 return self._send(200, json.dumps(set_maint(parts[1].lower(), payload)), "application/json")548 except Exception as e:549 return self._send(400, json.dumps({"error": str(e)}), "application/json")550 return self._send(404, "{}", "application/json")551552if __name__ == "__main__":553 srv = ThreadingHTTPServer(("0.0.0.0", PORT), H)554 print(f"MacLustr node pages on :{PORT} ({len(NODES)} nodes, self={SELF_SLUG})")555 srv.serve_forever()556