SPB Git forge
1commits 1branches 0releases
84.0 KBsize
maindefault branch
1 h agolast push
Python 99.8%
32.7 KB · 556 lines python
Raw Blame History
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=>({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}}[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