#!/usr/bin/env python3 """MacLustr node pages — live metrics + maintenance schedule. Stdlib only (Python 3.9+). Runs on m2u64, serves http://0.0.0.0:8003. Each node label QR -> https://node.maclustr.io/.""" import json, os, subprocess, threading, time, html from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse HERE = os.path.dirname(os.path.abspath(__file__)) PORT = 8003 SELF_SLUG = "m2u64" # this server runs ON m2u64 -> collect locally KEY = os.path.expanduser("~/.ssh/id_ed25519") MAINT_FILE = os.path.join(HERE, "maintenance.json") NODES = json.load(open(os.path.join(HERE, "nodes.json"))) BY_SLUG = {n["slug"]: n for n in NODES} # recurring maintenance schedule (checklist) — (title, frequency, detail) TASKS = [ ("Dust / ventilation cleaned", "Quarterly", "Blow out vents & fans, clear dust from intakes and exhaust grilles."), ("macOS updated", "Monthly", "Install latest security + OS updates; reboot if the installer requires it."), ("Disk space checked (>15% free)", "Monthly", "Verify the boot volume keeps >15% free; purge caches/old logs if low."), ("Scheduled reboot performed", "Monthly", "Clean reboot to clear memory pressure and apply pending updates."), ("SSH / network connectivity tested", "Monthly", "Confirm SSH key login, ping, and tunnel / ngrok reachability."), ("Backup / Time Machine verified", "Weekly", "Confirm the last backup completed and a test file restores correctly."), ("Temperatures / throttling checked", "Quarterly", "Check pmset therm and Activity Monitor for sustained thermal throttling."), ("Cabling & power inspected", "Quarterly", "Inspect power / USB / Ethernet cables, UPS, and surge protection."), ("System logs reviewed (Console)", "Monthly", "Scan Console for kernel panics, repeated errors, hardware warnings."), ("External SSD health checked", "Quarterly", "Review SMART status and free space on attached external SSDs."), ] # dropdown vocabularies for the maintenance form STATUS_OPTS = [ ("", "— Set status —"), ("done", "✅ Done"), ("progress", "🔧 In progress"), ("scheduled", "📅 Scheduled"), ("skip", "⏭️ N/A · Skipped"), ("attention", "⚠️ Needs attention"), ("failed", "❌ Failed"), ] STATUS_COLOR = { "done": "#16a34a", "progress": "#2563eb", "scheduled": "#7c3aed", "skip": "#9ca3af", "attention": "#d97706", "failed": "#dc2626", "": "transparent", } CONDITION_OPTS = [ ("", "— Overall condition —"), ("excellent", "🟢 Excellent"), ("good", "🟢 Good"), ("fair", "🟡 Fair"), ("poor", "🟠 Poor — service soon"), ("critical", "🔴 Needs service now"), ] BY_SUGGEST = ["SPB", "Remote", "On-site"] # datalist suggestions for "performed by" STATUS_LABEL = {v: lbl for v, lbl in STATUS_OPTS} CONDITION_LABEL = {v: lbl for v, lbl in CONDITION_OPTS} # ---- live metrics collector (runs on the target node) ---- COLLECT = r''' PAGESIZE=$(sysctl -n hw.pagesize 2>/dev/null || echo 16384) CPULINE=$(top -l 2 -n 0 2>/dev/null | grep "CPU usage" | tail -1) USR=$(echo "$CPULINE" | sed -E 's/.*: ([0-9.]+)% user.*/\1/') SYS=$(echo "$CPULINE" | sed -E 's/.*user, ([0-9.]+)% sys.*/\1/') IDLE=$(echo "$CPULINE" | sed -E 's/.*[, ]([0-9.]+)% idle.*/\1/') NCPU=$(sysctl -n hw.ncpu 2>/dev/null) LOAD=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}') MEMTOTAL=$(sysctl -n hw.memsize 2>/dev/null) VM=$(vm_stat 2>/dev/null) g(){ echo "$VM" | awk -F: "/$1/{gsub(/[^0-9]/,\"\",\$2); print \$2}"; } ACTIVE=$(g "Pages active"); WIRED=$(g "Pages wired down"); COMP=$(g "Pages occupied by compressor") FREE=$(g "Pages free"); SPEC=$(g "Pages speculative") DF=$(df -k / 2>/dev/null | awk 'NR==2{print $2","$3","$4","$5}') SWAPT=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*total = ([0-9.]+)M.*/\1/') SWAPU=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*used = ([0-9.]+)M.*/\1/') UP=$(uptime | sed -E 's/^.*up *//; s/, *[0-9]+ users?.*//; s/, *load.*//') BOOT=$(sysctl -n kern.boottime 2>/dev/null | sed -E 's/.*sec = ([0-9]+).*/\1/') NPROC=$(ps -A 2>/dev/null | wc -l | tr -d ' ') TOP=$(ps -Aceo pcpu,comm -r 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') TOPMEM=$(ps -Aceo rss,comm -m 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) THERM=$(pmset -g therm 2>/dev/null | sed -nE 's/.*CPU_Speed_Limit = ([0-9]+).*/\1/p') BATT=$(pmset -g batt 2>/dev/null | grep -oE '[0-9]+%' | head -1) OSV=$(sw_vers -productVersion 2>/dev/null) BUILD=$(sw_vers -buildVersion 2>/dev/null) printf "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" \ "$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" ''' _cache = {} _cache_lock = threading.Lock() TTL = 4 def _run(slug, node): if slug == SELF_SLUG: cmd = ["bash", "-c", COLLECT]; inp = None else: cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=8", "-i", KEY, node["fqdn"], "bash -s"]; inp = COLLECT try: p = subprocess.run(cmd, input=inp, capture_output=True, text=True, timeout=20) if p.returncode != 0: return {"online": False, "error": (p.stderr or "ssh failed").strip()[:200]} kv = {} for line in p.stdout.splitlines(): if "=" in line: k, v = line.split("=", 1); kv[k] = v return _parse(kv) except subprocess.TimeoutExpired: return {"online": False, "error": "timeout"} except Exception as e: return {"online": False, "error": str(e)[:200]} def _f(x, d=0.0): try: return float(x) except: return d def _procs(s): out = [] for chunk in (s or "").split(";"): if "|" in chunk: a, b = chunk.split("|", 1); out.append((a, b)) return out def _parse(kv): pg = _f(kv.get("PAGESIZE"), 16384) memtot = _f(kv.get("MEMTOTAL")) app = _f(kv.get("ACTIVE")) * pg wired = _f(kv.get("WIRED")) * pg comp = _f(kv.get("COMP")) * pg used = app + wired + comp idle = _f(kv.get("IDLE")) df = (kv.get("DF") or "0,0,0,0%").split(",") dtot = _f(df[0]) * 1024 if len(df) > 0 else 0 dused = _f(df[1]) * 1024 if len(df) > 1 else 0 dfree = _f(df[2]) * 1024 if len(df) > 2 else 0 dpct = _f("".join(ch for ch in (df[3] if len(df) > 3 else "0") if ch.isdigit() or ch == ".")) load = (kv.get("LOAD") or "").split() g = lambda b: round(b / 1e9, 1) topcpu = [{"v": a, "name": b} for a, b in _procs(kv.get("TOP"))][:5] topmem = [{"v": str(round(_f(a) / 1024)), "name": b} for a, b in _procs(kv.get("TOPMEM"))][:5] therm = kv.get("THERM", "") return { "online": True, "cpu_pct": round(max(0.0, min(100.0, 100.0 - idle)), 1), "cpu_user": round(_f(kv.get("USR")), 1), "cpu_sys": round(_f(kv.get("SYS")), 1), "ncpu": kv.get("NCPU", "?"), "mem_pct": round(used / memtot * 100, 1) if memtot else 0, "mem_used_gb": g(used), "mem_total_gb": g(memtot), "mem_app_gb": g(app), "mem_wired_gb": g(wired), "mem_comp_gb": g(comp), "disk_pct": round(dpct, 1), "disk_used_gb": g(dused), "disk_free_gb": g(dfree), "disk_total_gb": g(dtot), "load": load[:3], "swap_used": kv.get("SWAPU", "0"), "swap_total": kv.get("SWAPT", "0"), "uptime": (kv.get("UPTIME") or "?").strip(), "nproc": kv.get("NPROC", "?"), "osv": kv.get("OSV", "?"), "build": kv.get("BUILD", "?"), "ip": kv.get("IP", ""), "batt": kv.get("BATT", ""), "thermal": "Throttled (" + therm + "%)" if (therm and therm != "100") else ("Nominal" if therm else ""), "topcpu": topcpu, "topmem": topmem, "ts": int(time.time()), } def live(slug): node = BY_SLUG[slug]; now = time.time() with _cache_lock: c = _cache.get(slug) if c and now - c[0] < TTL: return c[1] data = _run(slug, node) with _cache_lock: _cache[slug] = (now, data) return data # ---- maintenance persistence ---- _maint_lock = threading.Lock() def load_maint(): try: return json.load(open(MAINT_FILE)) except: return {} def save_maint(d): with _maint_lock: json.dump(d, open(MAINT_FILE, "w"), indent=2) def get_maint(slug): return load_maint().get(slug, {"tasks": {}, "next": "", "log": "", "condition": "", "inspected_by": "", "reports": []}) def _build_report(payload, when): """Snapshot of what was done at save time -> a dated maintenance report.""" tasks = payload.get("tasks", {}) or {} items = [] for i, (title, fr, det) in enumerate(TASKS): t = tasks.get(str(i)) or tasks.get(i) or {} status = t.get("status") or ("done" if t.get("done") else "") if status or t.get("note") or t.get("date"): items.append({"title": title, "status": status, "date": t.get("date", ""), "by": t.get("by", ""), "note": t.get("note", "")}) return {"at": when, "by": payload.get("inspected_by", ""), "condition": payload.get("condition", ""), "next": payload.get("next", ""), "log": payload.get("log", ""), "items": items} def set_maint(slug, payload): d = load_maint() now = time.strftime("%Y-%m-%d %H:%M") prev = d.get(slug, {}) reports = list(prev.get("reports", [])) reports.insert(0, _build_report(payload, now)) # newest first reports = reports[:60] # keep last 60 reports d[slug] = {"tasks": payload.get("tasks", {}), "next": payload.get("next", ""), "log": payload.get("log", ""), "condition": payload.get("condition", ""), "inspected_by": payload.get("inspected_by", ""), "reports": reports, "saved_at": now} save_maint(d); return d[slug] def esc(s): return html.escape(str(s)) CSS = """ :root{--bg:#f2f3f7;--card:#fff;--ink:#1d1d1f;--mut:#6b7280;--line:#e6e8ef;--acc:#7c3aed; --green:#16a34a;--amber:#d97706;--red:#dc2626;--shadow:0 1px 3px rgba(16,24,40,.06),0 1px 2px rgba(16,24,40,.04)} *{box-sizing:border-box;margin:0;padding:0} html{-webkit-text-size-adjust:100%} body{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} a{color:var(--acc);text-decoration:none} .wrap{max-width:1080px;margin:0 auto;padding:18px 16px 64px} .bar-top{display:flex;align-items:center;gap:12px;margin-bottom:18px} .bar-top .logo{width:42px;height:42px;border-radius:11px;box-shadow:var(--shadow)} .bar-top .brand{font-weight:800;letter-spacing:.4px;font-size:13px;color:var(--mut)} .bar-top .back{margin-left:auto;font-size:13px;font-weight:600;color:var(--mut)} .hero{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:var(--shadow); display:flex;align-items:center;gap:16px;flex-wrap:wrap} .hero h1{font-size:clamp(34px,9vw,58px);font-weight:850;letter-spacing:-1.5px;line-height:1} .hero .meta{color:var(--mut);font-size:14px;margin-top:6px} .badge{padding:7px 16px;border-radius:10px;color:#fff;font-weight:800;font-size:16px;margin-left:auto} .chiptag{display:inline-block;background:#111;color:#fff;font-weight:800;font-size:13px;padding:4px 12px;border-radius:8px;margin-top:8px} .status{display:flex;align-items:center;gap:8px;margin:26px 2px 12px;font-size:13px;color:var(--mut)} .dot{width:10px;height:10px;border-radius:50%;background:#9ca3af;display:inline-block} .dot.on{background:var(--green);box-shadow:0 0 0 4px rgba(22,163,74,.15)} .dot.off{background:var(--red);box-shadow:0 0 0 4px rgba(220,38,38,.15)} h2{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--mut);margin:30px 4px 12px} .grid{display:grid;gap:12px} .g-metric{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))} .g-spec{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))} .g-two{grid-template-columns:1fr 1fr} @media(max-width:560px){.g-two{grid-template-columns:1fr}} .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 16px;box-shadow:var(--shadow)} .k{font-size:11px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)} .v{font-size:30px;font-weight:850;margin-top:4px;font-variant-numeric:tabular-nums} .v small{font-size:14px;color:var(--mut);font-weight:700} .sub{color:var(--mut);font-size:12.5px;margin-top:3px} .bar{height:8px;border-radius:5px;background:#eceef4;overflow:hidden;margin-top:10px} .bar>i{display:block;height:100%;border-radius:5px;width:0;transition:width .6s ease} .row{display:flex;justify-content:space-between;font-size:13.5px;padding:7px 0;border-bottom:1px solid var(--line)} .row:last-child{border-bottom:0} .row b{font-weight:700} .proc{display:flex;justify-content:space-between;font-size:13px;padding:5px 0;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums} .proc:last-child{border-bottom:0} .proc .nm{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:72%} .proc .pc{color:var(--mut);font-weight:700} .mwrap{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden} .mhead{display:grid;grid-template-columns:150px 1fr 140px 130px 1.1fr;gap:12px;padding:11px 16px;border-bottom:1px solid var(--line); background:#fafafe;font-size:10.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)} .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} .mrow:last-child{border-bottom:0} .mrow .t{font-weight:600;font-size:14.5px} .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} .mrow .det{font-size:12px;color:var(--mut);margin-top:5px;line-height:1.4} @media(max-width:620px){.mhead{display:none}.mrow{grid-template-columns:1fr;row-gap:9px}} input[type=checkbox]{width:22px;height:22px;accent-color:var(--green);cursor:pointer} input[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} input:focus,textarea:focus,select:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,58,237,.12)} textarea{min-height:80px;resize:vertical} select{-webkit-appearance:none;appearance:none;cursor:pointer;padding-right:30px; background-image:url("data:image/svg+xml;utf8,"); background-repeat:no-repeat;background-position:right 11px center} .savebar{display:flex;align-items:center;gap:14px;margin-top:16px;flex-wrap:wrap} .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)} .btn:active{transform:translateY(1px)} .saved{color:var(--green);font-weight:700;opacity:0;transition:opacity .3s} .muted{color:var(--mut);font-size:13px} .rcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;box-shadow:var(--shadow);margin-bottom:12px} .rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding-bottom:10px;margin-bottom:10px;border-bottom:1px solid var(--line)} .rhead b{font-size:15px;font-variant-numeric:tabular-nums} .rby{font-size:12.5px;color:var(--mut);font-weight:700} .rcond{margin-left:auto;font-size:12.5px;font-weight:800;background:#f4f4fb;border:1px solid var(--line);padding:3px 10px;border-radius:8px} .ritem{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:7px 0;border-bottom:1px dashed var(--line)} .ritem:last-child{border-bottom:0} .ritem .rt{font-size:13.5px;font-weight:600} .ritem .rd{font-size:12px;color:var(--mut);font-variant-numeric:tabular-nums} .ritem .rb{font-size:12px;color:var(--mut);font-weight:700} .ritem .rn{flex-basis:100%;font-size:12.5px;color:var(--mut);padding-left:2px} .schip{font-size:11px;font-weight:800;padding:2px 9px;border-radius:7px;white-space:nowrap} .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} .rnext{margin-top:8px;font-size:12.5px;color:var(--acc);font-weight:700} .foot{margin-top:34px;color:var(--mut);font-size:12px;text-align:center} """ def node_page(n): slug = n["slug"]; tc = n["tcolor"] storage = f'{n["internal"]} SSD' + (f' + {n["external"]} external' if n["external"] else "") status_opts = "".join(f'' for v, lbl in STATUS_OPTS) rows = "".join( f'
' f'' f'
{esc(t)}
{esc(fr)}' f'
{esc(det)}
' f'' f'' f'' f'
' for i, (t, fr, det) in enumerate(TASKS)) cond_opts = "".join(f'' for v, lbl in CONDITION_OPTS) by_datalist = "".join(f'