adopt: état déployé sur M2U64 adopté comme source de vérité (remote-first, 2026-10-02)
9 changed files +3,241 −0
added
.gitignore
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +node_modules/ | |
| 2 | +.venv/ | |
| 3 | +venv/ | |
| 4 | +.next/ | |
| 5 | +__pycache__/ | |
| 6 | +*.pyc | |
| 7 | +*.log | |
| 8 | +.DS_Store | |
| 9 | +.env | |
| 10 | +.env.local | |
| 11 | +.pm2/ | |
added
landing.py
+582 −0
@@ -0,0 +1,582 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""MacLustr public website (multi-page). Stdlib only (Python 3.9+). | |
| 3 | +Runs on m2u64, serves http://0.0.0.0:8004 -> ngrok www.maclustr.io.""" | |
| 4 | +import json, os, html | |
| 5 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| 6 | +from urllib.parse import urlparse | |
| 7 | + | |
| 8 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
| 9 | +PORT = 8004 | |
| 10 | +CONTACT = "spbou4@protonmail.com" | |
| 11 | +NODES = json.load(open(os.path.join(HERE, "nodes.json"))) | |
| 12 | +TIER_ORDER = {"ULTRA": 0, "MAX": 1, "PRO": 2, "BASE": 3} | |
| 13 | +TCOLOR = {"ULTRA": "#5B21B6", "MAX": "#7C3AED", "PRO": "#2563EB", "BASE": "#0891B2"} | |
| 14 | + | |
| 15 | +def esc(s): return html.escape(str(s)) | |
| 16 | +def gb_to_tb(g): return f"{g/1024:.1f} TB" if g >= 1024 else f"{g} GB" | |
| 17 | +def store_gb(v): | |
| 18 | + v = (v or "").strip() | |
| 19 | + if v.endswith("TB"): return int(float(v.replace("TB", "").strip())) * 1024 | |
| 20 | + if v.endswith("GB"): return int(float(v.replace("GB", "").strip())) | |
| 21 | + return 0 | |
| 22 | + | |
| 23 | +# ---- modern line icons (Lucide-style, stroke=currentColor) ---- | |
| 24 | +_P = { | |
| 25 | +"cpu":'<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M20 9h2M20 14h2M2 9h2M2 14h2"/>', | |
| 26 | +"server":'<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/>', | |
| 27 | +"cloud":'<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>', | |
| 28 | +"zap":'<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z"/>', | |
| 29 | +"lock":'<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', | |
| 30 | +"command":'<path d="M15 6a3 3 0 1 1 3 3h-3V6ZM9 6a3 3 0 1 0-3 3h3V6ZM15 18a3 3 0 1 0 3-3h-3v3ZM9 18a3 3 0 1 1-3-3h3v3Z"/><rect x="9" y="9" width="6" height="6"/>', | |
| 31 | +"mail":'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/>', | |
| 32 | +"arrow":'<path d="M5 12h14M13 6l6 6-6 6"/>', | |
| 33 | +"layers":'<path d="m12 2 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5"/><path d="m3 17 9 5 9-5"/>', | |
| 34 | +"db":'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>', | |
| 35 | +"globe":'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20Z"/>', | |
| 36 | +"check":'<path d="M20 6 9 17l-5-5"/>', | |
| 37 | +"gauge":'<path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M13.4 12.6 19 7"/><path d="M3.5 18a10 10 0 1 1 17 0"/>', | |
| 38 | +"gpu":'<rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="9" cy="12" r="2.5"/><path d="M15 10h4M15 14h4M2 18v2"/>', | |
| 39 | +"chip":'<rect x="5" y="5" width="14" height="14" rx="2"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>', | |
| 40 | +"hd":'<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V11a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v10"/><path d="M6 14h.01"/>', | |
| 41 | +} | |
| 42 | +def icon(name, size=24, cls=""): | |
| 43 | + return (f'<svg class="{cls}" width="{size}" height="{size}" viewBox="0 0 24 24" fill="none" ' | |
| 44 | + f'stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">{_P.get(name,"")}</svg>') | |
| 45 | + | |
| 46 | +CSS = """ | |
| 47 | +:root{--bg:#fafbfd;--card:#fff;--ink:#0e1016;--ink2:#3a3f4b;--mut:#737a89;--line:#edeff4; | |
| 48 | +--acc:#7c3aed;--acc2:#2f6bff;--ring:rgba(124,58,237,.14); | |
| 49 | +--sh:0 1px 2px rgba(16,24,40,.04),0 1px 3px rgba(16,24,40,.06); | |
| 50 | +--sh2:0 12px 40px rgba(16,24,40,.10);--r:22px} | |
| 51 | +*{box-sizing:border-box;margin:0;padding:0} | |
| 52 | +html{-webkit-text-size-adjust:100%;scroll-behavior:smooth} | |
| 53 | +body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text",Helvetica,Arial,sans-serif; | |
| 54 | +background:var(--bg);color:var(--ink);line-height:1.6;-webkit-font-smoothing:antialiased;letter-spacing:-.01em} | |
| 55 | +a{color:inherit;text-decoration:none} | |
| 56 | +svg{display:block} | |
| 57 | +.wrap{max-width:1080px;margin:0 auto;padding:0 20px} | |
| 58 | +/* nav */ | |
| 59 | +.nav{position:sticky;top:0;z-index:20;background:rgba(250,251,253,.72);backdrop-filter:saturate(180%) blur(16px);border-bottom:1px solid var(--line)} | |
| 60 | +.nav .in{max-width:1080px;margin:0 auto;padding:13px 20px;display:flex;align-items:center;gap:12px} | |
| 61 | +.nav .logo{width:32px;height:32px;border-radius:9px;box-shadow:var(--sh)} | |
| 62 | +.nav .nm{font-weight:800;font-size:17px;letter-spacing:-.02em} | |
| 63 | +.nav .links{margin-left:auto;display:flex;align-items:center;gap:6px} | |
| 64 | +.nav .links a{padding:8px 13px;border-radius:11px;font-size:14px;font-weight:600;color:var(--mut);transition:.15s} | |
| 65 | +.nav .links a:hover{color:var(--ink);background:#f1f2f7} | |
| 66 | +.nav .links a.on{color:var(--ink);background:#eef0f6} | |
| 67 | +.nav .cta{margin-left:6px;background:var(--ink);color:#fff!important;padding:9px 16px!important;border-radius:11px;font-weight:700} | |
| 68 | +.nav .cta:hover{background:#000;opacity:1} | |
| 69 | +@media(max-width:640px){.nav .links a:not(.cta){display:none}} | |
| 70 | +/* generic */ | |
| 71 | +.kick{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;font-weight:700;letter-spacing:.3px;color:var(--acc); | |
| 72 | +background:var(--ring);padding:6px 13px;border-radius:999px} | |
| 73 | +.eyebrow{text-align:center} | |
| 74 | +h1{font-size:clamp(40px,7.5vw,74px);font-weight:830;letter-spacing:-.035em;line-height:1.02} | |
| 75 | +h2{font-size:clamp(28px,5vw,42px);font-weight:800;letter-spacing:-.03em;line-height:1.08} | |
| 76 | +.grad{background:linear-gradient(105deg,var(--acc),var(--acc2));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent} | |
| 77 | +.lead{font-size:clamp(16px,2.4vw,20px);color:var(--mut);max-width:660px;line-height:1.55} | |
| 78 | +.section{padding:62px 0} | |
| 79 | +.center{text-align:center}.mx{margin-left:auto;margin-right:auto} | |
| 80 | +.btn{display:inline-flex;align-items:center;gap:9px;font-weight:750;font-size:15.5px;padding:14px 24px;border-radius:14px;transition:.15s} | |
| 81 | +.btn.p{background:var(--ink);color:#fff;box-shadow:var(--sh2)} | |
| 82 | +.btn.p:hover{transform:translateY(-1px)} | |
| 83 | +.btn.g{background:var(--card);color:var(--ink);border:1px solid var(--line);box-shadow:var(--sh)} | |
| 84 | +.btn.g:hover{background:#f6f7fb} | |
| 85 | +.row-btn{display:flex;gap:12px;flex-wrap:wrap} | |
| 86 | +.hero{padding:74px 0 24px;text-align:center} | |
| 87 | +.hero .logo{width:84px;height:84px;border-radius:22px;box-shadow:var(--sh2);margin:0 auto 24px} | |
| 88 | +.hero .lead{margin:20px auto 0} | |
| 89 | +.hero .row-btn{justify-content:center;margin-top:32px} | |
| 90 | +/* stats */ | |
| 91 | +.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:13px;margin-top:54px} | |
| 92 | +@media(max-width:780px){.stats{grid-template-columns:repeat(2,1fr)}} | |
| 93 | +.stat{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:22px 16px;text-align:center;box-shadow:var(--sh)} | |
| 94 | +.stat .ic{color:var(--acc);margin:0 auto 10px;width:24px} | |
| 95 | +.stat .n{font-size:clamp(24px,4.6vw,34px);font-weight:830;letter-spacing:-.03em;font-variant-numeric:tabular-nums} | |
| 96 | +.stat .l{font-size:12px;font-weight:650;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin-top:3px} | |
| 97 | +/* feature cards */ | |
| 98 | +.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:36px} | |
| 99 | +@media(max-width:820px){.cards{grid-template-columns:1fr}} | |
| 100 | +.fcard{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:26px;box-shadow:var(--sh);transition:.18s} | |
| 101 | +.fcard:hover{box-shadow:var(--sh2);transform:translateY(-2px)} | |
| 102 | +.fcard .ic{width:50px;height:50px;border-radius:15px;display:flex;align-items:center;justify-content:center;color:#fff; | |
| 103 | +background:linear-gradient(135deg,var(--acc),var(--acc2));box-shadow:0 6px 16px var(--ring);margin-bottom:16px} | |
| 104 | +.fcard h3{font-size:20px;font-weight:780;letter-spacing:-.02em} | |
| 105 | +.fcard p{color:var(--mut);font-size:14.8px;margin-top:8px} | |
| 106 | +.fcard ul{list-style:none;margin-top:14px;display:grid;gap:8px} | |
| 107 | +.fcard li{display:flex;gap:9px;font-size:14px;color:var(--ink2)} | |
| 108 | +.fcard li svg{color:var(--acc);flex:0 0 auto;margin-top:3px} | |
| 109 | +/* nodes */ | |
| 110 | +.tier{display:flex;align-items:center;gap:12px;margin:34px 0 14px} | |
| 111 | +.tier .pill{display:inline-flex;align-items:center;color:#fff;font-weight:750;font-size:13px;padding:6px 15px;border-radius:10px} | |
| 112 | +.tier .d{color:var(--mut);font-size:13.5px} | |
| 113 | +.nodes{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:14px} | |
| 114 | +.node{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:18px;box-shadow:var(--sh);position:relative;overflow:hidden} | |
| 115 | +.node:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--tc)} | |
| 116 | +.node .h{display:flex;align-items:baseline;gap:8px} | |
| 117 | +.node .nm{font-size:22px;font-weight:820;letter-spacing:-.02em} | |
| 118 | +.node .md{margin-left:auto;font-size:12px;color:var(--mut);font-weight:600} | |
| 119 | +.node .ch{font-size:12.5px;color:var(--ink2);font-weight:700;margin-top:1px} | |
| 120 | +.node .sp{display:grid;grid-template-columns:1fr 1fr;gap:6px 16px;margin-top:14px;font-size:13.5px} | |
| 121 | +.node .sp>div{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:5px} | |
| 122 | +.node .sp span{color:var(--mut)}.node .sp b{font-weight:700;font-variant-numeric:tabular-nums} | |
| 123 | +/* split / about */ | |
| 124 | +.split{display:grid;grid-template-columns:1.1fr .9fr;gap:40px;align-items:center} | |
| 125 | +@media(max-width:820px){.split{grid-template-columns:1fr;gap:24px}} | |
| 126 | +.mini{display:grid;grid-template-columns:1fr 1fr;gap:12px} | |
| 127 | +.mini .stat{padding:18px 14px;text-align:left}.mini .stat .ic{margin:0 0 8px} | |
| 128 | +/* steps */ | |
| 129 | +.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:34px;counter-reset:s} | |
| 130 | +@media(max-width:820px){.steps{grid-template-columns:1fr}} | |
| 131 | +.step{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:24px;box-shadow:var(--sh)} | |
| 132 | +.step .no{width:34px;height:34px;border-radius:11px;background:var(--ring);color:var(--acc);font-weight:800;display:flex;align-items:center;justify-content:center;margin-bottom:13px} | |
| 133 | +.step h3{font-size:18px;font-weight:760}.step p{color:var(--mut);font-size:14.5px;margin-top:6px} | |
| 134 | +/* contact band */ | |
| 135 | +.band{background:linear-gradient(140deg,#15102a,#1f1640 55%,#10213f);color:#fff;border-radius:30px;padding:clamp(36px,6vw,64px);text-align:center;box-shadow:var(--sh2)} | |
| 136 | +.band h2{color:#fff}.band p{color:#cdc8e6;max-width:620px;margin:14px auto 0;font-size:17px} | |
| 137 | +.band .mail{display:inline-flex;align-items:center;gap:11px;margin-top:28px;background:#fff;color:#15102a;font-weight:800; | |
| 138 | +font-size:clamp(15px,3.6vw,21px);padding:16px 28px;border-radius:15px;word-break:break-all} | |
| 139 | +.chips{display:flex;gap:9px;justify-content:center;flex-wrap:wrap;margin-top:22px} | |
| 140 | +.chips span{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.16);padding:7px 15px;border-radius:999px;font-size:13.5px;font-weight:600} | |
| 141 | +/* pricing plans */ | |
| 142 | +.plans{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:36px} | |
| 143 | +@media(max-width:900px){.plans{grid-template-columns:repeat(2,1fr)}} | |
| 144 | +@media(max-width:520px){.plans{grid-template-columns:1fr}} | |
| 145 | +.plan{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:24px;box-shadow:var(--sh);display:flex;flex-direction:column} | |
| 146 | +.plan.hot{border-color:transparent;background:linear-gradient(160deg,#1a1230,#231a40);color:#fff;box-shadow:var(--sh2)} | |
| 147 | +.plan .tg{display:inline-flex;align-self:flex-start;color:#fff;font-weight:750;font-size:12px;padding:5px 12px;border-radius:8px;letter-spacing:.4px} | |
| 148 | +.plan h3{font-size:21px;font-weight:800;margin-top:14px;letter-spacing:-.02em} | |
| 149 | +.plan .from{font-size:12.5px;color:var(--mut);margin-top:14px;font-weight:600} | |
| 150 | +.plan.hot .from{color:#bdb6da} | |
| 151 | +.plan .price{font-size:40px;font-weight:840;letter-spacing:-.03em;line-height:1} | |
| 152 | +.plan .price small{font-size:15px;font-weight:700;color:var(--mut)} | |
| 153 | +.plan.hot .price small{color:#bdb6da} | |
| 154 | +.plan p{color:var(--mut);font-size:13.8px;margin-top:12px} | |
| 155 | +.plan.hot p{color:#cdc8e6} | |
| 156 | +.plan ul{list-style:none;margin-top:14px;display:grid;gap:7px;flex:1} | |
| 157 | +.plan li{display:flex;gap:8px;font-size:13.3px;color:var(--ink2)} | |
| 158 | +.plan.hot li{color:#e7e2f5} | |
| 159 | +.plan li svg{color:var(--acc);flex:0 0 auto;margin-top:2px} | |
| 160 | +.plan.hot li svg{color:#b794ff} | |
| 161 | +.plan a.cta2{margin-top:18px;text-align:center;background:var(--ink);color:#fff;font-weight:750;padding:12px;border-radius:12px;font-size:14.5px} | |
| 162 | +.plan.hot a.cta2{background:#fff;color:#1a1230} | |
| 163 | +/* pricing table */ | |
| 164 | +.ptable{margin-top:18px;border:1px solid var(--line);border-radius:18px;overflow:hidden;box-shadow:var(--sh);background:var(--card)} | |
| 165 | +.pscroll{overflow-x:auto} | |
| 166 | +table.pg{border-collapse:collapse;width:100%;min-width:620px;font-size:14px} | |
| 167 | +table.pg th,table.pg td{text-align:right;padding:13px 14px;border-bottom:1px solid var(--line);white-space:nowrap} | |
| 168 | +table.pg th:first-child,table.pg td:first-child,table.pg th:nth-child(2),table.pg td:nth-child(3){text-align:left} | |
| 169 | +table.pg thead th{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);font-weight:750;background:#fafbfd} | |
| 170 | +table.pg tbody tr:last-child td{border-bottom:0} | |
| 171 | +table.pg tbody tr:hover{background:#fafbfd} | |
| 172 | +table.pg .node-nm{font-weight:800;letter-spacing:-.01em} | |
| 173 | +table.pg .tg{display:inline-block;color:#fff;font-size:11px;font-weight:750;padding:3px 9px;border-radius:7px} | |
| 174 | +table.pg .spec{color:var(--mut);font-variant-numeric:tabular-nums} | |
| 175 | +table.pg .ml{font-weight:800;font-variant-numeric:tabular-nums} | |
| 176 | +table.pg .aws{color:var(--mut);text-decoration:line-through;font-variant-numeric:tabular-nums} | |
| 177 | +table.pg .num{font-variant-numeric:tabular-nums} | |
| 178 | +.note{color:var(--mut);font-size:12.5px;margin-top:14px;line-height:1.6} | |
| 179 | +/* whole-cluster banner */ | |
| 180 | +.clusterbox{background:linear-gradient(140deg,#15102a,#1f1640 55%,#10213f);color:#fff;border-radius:28px;padding:clamp(28px,5vw,52px);box-shadow:var(--sh2);margin-top:34px} | |
| 181 | +.clusterbox .kick{background:rgba(255,255,255,.14);color:#fff} | |
| 182 | +.clusterbox h2{color:#fff;margin-top:14px} | |
| 183 | +.clusterbox .sub2{color:#cdc8e6;margin-top:10px;font-size:16.5px;max-width:640px} | |
| 184 | +.cl-prices{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:26px} | |
| 185 | +@media(max-width:680px){.cl-prices{grid-template-columns:repeat(2,1fr)}} | |
| 186 | +.cl-prices>div{background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.15);border-radius:16px;padding:18px 16px} | |
| 187 | +.cl-prices .hl{background:#fff;color:#15102a;border-color:#fff} | |
| 188 | +.cl-prices .l{font-size:11.5px;font-weight:750;text-transform:uppercase;letter-spacing:.5px;opacity:.82} | |
| 189 | +.cl-prices .n{font-size:clamp(22px,3.6vw,32px);font-weight:840;letter-spacing:-.03em;margin-top:5px;font-variant-numeric:tabular-nums} | |
| 190 | +.cl-prices .p{font-size:12px;opacity:.7;margin-top:2px} | |
| 191 | +.cl-incl{display:flex;flex-wrap:wrap;gap:8px;margin-top:24px} | |
| 192 | +.cl-incl span{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.16);padding:7px 14px;border-radius:999px;font-size:13px;font-weight:600} | |
| 193 | +.clusterbox .btn{margin-top:26px;background:#fff;color:#15102a} | |
| 194 | +.clusterbox .fine{color:#b3aace;font-size:12.5px;margin-top:16px;line-height:1.6} | |
| 195 | +/* footer */ | |
| 196 | +footer{border-top:1px solid var(--line);margin-top:30px} | |
| 197 | +footer .in{max-width:1080px;margin:0 auto;padding:30px 20px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;color:var(--mut);font-size:13.5px} | |
| 198 | +footer .nm{display:flex;align-items:center;gap:9px;font-weight:750;color:var(--ink)} | |
| 199 | +footer .nm img{width:24px;height:24px;border-radius:7px} | |
| 200 | +footer .lk{margin-left:auto;display:flex;gap:18px} | |
| 201 | +footer .lk a:hover{color:var(--ink)} | |
| 202 | +""" | |
| 203 | + | |
| 204 | +def nav(active): | |
| 205 | + def lk(href, label, key): | |
| 206 | + return f'<a href="{href}" class="{"on" if active==key else ""}">{label}</a>' | |
| 207 | + return f"""<div class="nav"><div class="in"> | |
| 208 | +<a href="/" style="display:flex;align-items:center;gap:11px"><img class="logo" src="/logo.svg" alt=""><span class="nm">MacLustr</span></a> | |
| 209 | +<nav class="links">{lk('/','Home','home')}{lk('/nodes','Nodes','nodes')}{lk('/services','Services','services')}{lk('/pricing','Pricing','pricing')}{lk('/about','About','about')} | |
| 210 | +<a href="/contact" class="cta">Request access</a></nav></div></div>""" | |
| 211 | + | |
| 212 | +def footer(): | |
| 213 | + return f"""<footer><div class="in"> | |
| 214 | +<span class="nm"><img src="/logo.svg" alt="">MacLustr</span> | |
| 215 | +<span>Self-hosted Apple Silicon compute cluster · {len(NODES)} nodes</span> | |
| 216 | +<span class="lk"><a href="/nodes">Nodes</a><a href="/services">Services</a><a href="/about">About</a><a href="mailto:{CONTACT}">Contact</a></span> | |
| 217 | +</div></footer>""" | |
| 218 | + | |
| 219 | +def shell(title, active, body, desc=""): | |
| 220 | + return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| 221 | +<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| 222 | +<meta name="theme-color" content="#fafbfd"><meta name="description" content="{esc(desc)}"> | |
| 223 | +<title>{esc(title)}</title><style>{CSS}</style></head><body> | |
| 224 | +{nav(active)}{body}{footer()}</body></html>""" | |
| 225 | + | |
| 226 | +def totals(): | |
| 227 | + return dict( | |
| 228 | + n=len(NODES), | |
| 229 | + cpu=sum(n["ncpu"] for n in NODES), | |
| 230 | + gpu=sum(n["gpu"] for n in NODES), | |
| 231 | + ne=sum(n["ne"] for n in NODES), | |
| 232 | + ram=sum(n["mem"] for n in NODES), | |
| 233 | + store=sum(store_gb(n["internal"]) + store_gb(n["external"]) for n in NODES), | |
| 234 | + ) | |
| 235 | + | |
| 236 | +def stat(ic, n, l): | |
| 237 | + return f'<div class="stat"><div class="ic">{icon(ic)}</div><div class="n">{n}</div><div class="l">{l}</div></div>' | |
| 238 | + | |
| 239 | +def stats_block(): | |
| 240 | + t = totals() | |
| 241 | + return (f'<div class="stats">' | |
| 242 | + f'{stat("server",t["n"],"Nodes")}' | |
| 243 | + f'{stat("cpu",t["cpu"],"CPU cores")}' | |
| 244 | + f'{stat("chip",t["ram"],"GB unified RAM")}' | |
| 245 | + f'{stat("gpu",t["gpu"],"GPU cores")}' | |
| 246 | + f'{stat("hd",gb_to_tb(t["store"]),"SSD storage")}' | |
| 247 | + f'</div>') | |
| 248 | + | |
| 249 | +def node_card(n): | |
| 250 | + storage = f'{n["internal"]} SSD' + (f' + {n["external"]} ext' if n["external"] else "") | |
| 251 | + return (f'<div class="node" style="--tc:{TCOLOR.get(n["tier"],"#888")}">' | |
| 252 | + f'<div class="h"><span class="nm">{esc(n["alias"])}</span><span class="md">{esc(n["model"])}</span></div>' | |
| 253 | + f'<div class="ch">{esc(n["chip"])}</div>' | |
| 254 | + f'<div class="sp">' | |
| 255 | + f'<div><span>CPU</span><b>{n["ncpu"]}c</b></div>' | |
| 256 | + f'<div><span>GPU</span><b>{n["gpu"]}c</b></div>' | |
| 257 | + f'<div><span>RAM</span><b>{n["mem"]} GB</b></div>' | |
| 258 | + f'<div><span>Neural</span><b>{n["ne"]}c</b></div>' | |
| 259 | + f'<div style="grid-column:1/3"><span>Storage</span><b>{esc(storage)}</b></div>' | |
| 260 | + f'</div></div>') | |
| 261 | + | |
| 262 | +def nodes_grouped(): | |
| 263 | + order = sorted(NODES, key=lambda n: (TIER_ORDER.get(n["tier"], 9), -n["ncpu"], -n["mem"])) | |
| 264 | + tiers = {} | |
| 265 | + for n in order: tiers.setdefault(n["tier"], []).append(n) | |
| 266 | + desc = {"ULTRA": "Mac Studio · M-series Ultra — heaviest compute & training", | |
| 267 | + "MAX": "Mac Studio / MacBook Pro · M-series Max — high-throughput workloads", | |
| 268 | + "PRO": "Mac mini · M-series Pro — balanced services & build agents", | |
| 269 | + "BASE": "Mac mini / MacBook Air · M-series — lightweight always-on services"} | |
| 270 | + out = "" | |
| 271 | + for t, lst in tiers.items(): | |
| 272 | + out += (f'<div class="tier"><span class="pill" style="background:{TCOLOR.get(t,"#666")}">{esc(t)}</span>' | |
| 273 | + f'<span class="d">{len(lst)} node{"s" if len(lst)>1 else ""} · {esc(desc.get(t,""))}</span></div>' | |
| 274 | + f'<div class="nodes">{"".join(node_card(n) for n in lst)}</div>') | |
| 275 | + return out | |
| 276 | + | |
| 277 | +# ---------- pages ---------- | |
| 278 | +def page_home(): | |
| 279 | + t = totals() | |
| 280 | + body = f"""<div class="wrap"> | |
| 281 | +<div class="hero"> | |
| 282 | + <img class="logo" src="/logo.svg" alt="MacLustr"> | |
| 283 | + <div class="eyebrow"><span class="kick">{icon('command',16)} Self-hosted · Apple Silicon</span></div> | |
| 284 | + <h1 style="margin-top:18px">The <span class="grad">self-hosted</span><br>Apple Silicon cluster</h1> | |
| 285 | + <p class="lead">A private cluster of {t['n']} Apple Silicon Macs — Mac Studio, MacBook Pro, Mac mini and MacBook Air — networked for distributed compute, hosting and private cloud.</p> | |
| 286 | + <div class="row-btn"><a class="btn p" href="/contact">Request access {icon('arrow',18)}</a><a class="btn g" href="/nodes">Explore the nodes</a></div> | |
| 287 | + {stats_block()} | |
| 288 | +</div> | |
| 289 | + | |
| 290 | +<div class="section center"> | |
| 291 | + <div class="eyebrow"><span class="kick">Why MacLustr</span></div> | |
| 292 | + <h2 style="margin-top:14px">Real hardware, not a rented VM</h2> | |
| 293 | + <p class="lead mx" style="margin-top:12px">We pool genuine Apple Silicon — {t['cpu']} CPU cores, {t['ram']} GB unified memory, {t['gpu']} GPU cores and {t['ne']} Neural Engine cores — into one cluster you fully control.</p> | |
| 294 | + <div class="cards" style="text-align:left"> | |
| 295 | + <div class="fcard"><div class="ic">{icon('zap')}</div><h3>Distributed compute</h3><p>Spread heavy jobs across {t['n']} machines — ML training & inference, simulation, batch processing and rendering.</p></div> | |
| 296 | + <div class="fcard"><div class="ic">{icon('cloud')}</div><h3>Private cloud</h3><p>Your own always-on infrastructure: APIs, databases, model servers and storage on dedicated hardware.</p></div> | |
| 297 | + <div class="fcard"><div class="ic">{icon('command')}</div><h3>Native macOS</h3><p>True Apple Silicon for macOS/iOS builds, CI agents and anything that must run on real Macs.</p></div> | |
| 298 | + </div> | |
| 299 | +</div> | |
| 300 | + | |
| 301 | +<div class="section">{contact_band()}</div> | |
| 302 | +</div>""" | |
| 303 | + return shell("MacLustr — Self-Hosted Apple Silicon Compute Cluster", "home", body, | |
| 304 | + f"MacLustr is a self-hosted cluster of {t['n']} Apple Silicon Macs for compute, hosting and private cloud.") | |
| 305 | + | |
| 306 | +def page_nodes(): | |
| 307 | + t = totals() | |
| 308 | + body = f"""<div class="wrap"> | |
| 309 | +<div class="section" style="padding-bottom:10px;text-align:center"> | |
| 310 | + <div class="eyebrow"><span class="kick">{icon('layers',16)} The fleet</span></div> | |
| 311 | + <h2 style="margin-top:14px">Every node, fully specced</h2> | |
| 312 | + <p class="lead mx" style="margin-top:12px">{t['n']} dedicated Apple Silicon Macs, grouped by tier. Pick the one that matches your workload.</p> | |
| 313 | +</div> | |
| 314 | +<div style="padding-bottom:30px">{stats_block()}</div> | |
| 315 | +<div style="padding-bottom:50px">{nodes_grouped()}</div> | |
| 316 | +<div class="section" style="padding-top:0">{contact_band()}</div> | |
| 317 | +</div>""" | |
| 318 | + return shell("Nodes — MacLustr", "nodes", body, "Full specifications of every MacLustr cluster node.") | |
| 319 | + | |
| 320 | +def page_services(): | |
| 321 | + body = f"""<div class="wrap"> | |
| 322 | +<div class="section" style="padding-bottom:10px;text-align:center"> | |
| 323 | + <div class="eyebrow"><span class="kick">{icon('server',16)} Services</span></div> | |
| 324 | + <h2 style="margin-top:14px">What you can run on MacLustr</h2> | |
| 325 | + <p class="lead mx" style="margin-top:12px">Three ways to put our Apple Silicon to work — rent it by the job, host a service, or get a private slice of cloud.</p> | |
| 326 | + <div class="cards" style="text-align:left"> | |
| 327 | + <div class="fcard"><div class="ic">{icon('gauge')}</div><h3>Compute</h3> | |
| 328 | + <p>Rent cores and GPU / Neural Engine time for demanding jobs.</p> | |
| 329 | + <ul><li>{icon('check',16)} ML training & inference</li><li>{icon('check',16)} Simulation & data processing</li><li>{icon('check',16)} Batch jobs & rendering</li></ul></div> | |
| 330 | + <div class="fcard"><div class="ic">{icon('globe')}</div><h3>Hosting</h3> | |
| 331 | + <p>Run a service on a dedicated node with a real public endpoint.</p> | |
| 332 | + <ul><li>{icon('check',16)} Websites & APIs</li><li>{icon('check',16)} Bots & background workers</li><li>{icon('check',16)} Public HTTPS endpoint</li></ul></div> | |
| 333 | + <div class="fcard"><div class="ic">{icon('lock')}</div><h3>Private cloud</h3> | |
| 334 | + <p>An isolated, always-on slice of the cluster that's yours.</p> | |
| 335 | + <ul><li>{icon('check',16)} Storage & databases</li><li>{icon('check',16)} Model & app servers</li><li>{icon('check',16)} Dedicated, isolated resources</li></ul></div> | |
| 336 | + </div> | |
| 337 | +</div> | |
| 338 | + | |
| 339 | +<div class="section" style="padding-top:20px"> | |
| 340 | + <div class="center"><div class="eyebrow"><span class="kick">How it works</span></div> | |
| 341 | + <h2 style="margin-top:14px">From request to running</h2></div> | |
| 342 | + <div class="steps"> | |
| 343 | + <div class="step"><div class="no">1</div><h3>Tell us your workload</h3><p>Email a short description — what you want to run, rough resources and how long.</p></div> | |
| 344 | + <div class="step"><div class="no">2</div><h3>We match a node</h3><p>We pick the right tier — Ultra, Max, Pro or Base — and set up access.</p></div> | |
| 345 | + <div class="step"><div class="no">3</div><h3>You go live</h3><p>Get credentials / an endpoint and start running. Monitor each node's live page anytime.</p></div> | |
| 346 | + </div> | |
| 347 | +</div> | |
| 348 | + | |
| 349 | +<div class="section" style="padding-top:0">{contact_band()}</div> | |
| 350 | +</div>""" | |
| 351 | + return shell("Services — MacLustr", "services", body, "Compute, hosting and private cloud on the MacLustr Apple Silicon cluster.") | |
| 352 | + | |
| 353 | +def page_about(): | |
| 354 | + t = totals() | |
| 355 | + # model breakdown | |
| 356 | + counts = {} | |
| 357 | + for n in NODES: counts[n["model"]] = counts.get(n["model"], 0) + 1 | |
| 358 | + chips = " · ".join(f"{v}× {k}" for k, v in sorted(counts.items(), key=lambda x: -x[1])) | |
| 359 | + body = f"""<div class="wrap"> | |
| 360 | +<div class="section" style="padding-bottom:10px"> | |
| 361 | + <div class="split"> | |
| 362 | + <div> | |
| 363 | + <div class="eyebrow" style="text-align:left"><span class="kick">{icon('cpu',16)} About</span></div> | |
| 364 | + <h2 style="margin-top:16px">A cluster built from real Macs</h2> | |
| 365 | + <p class="lead" style="margin-top:14px">MacLustr is a self-hosted cluster of {t['n']} Apple Silicon Macs running native macOS. Instead of renting generic cloud VMs, we pool genuine Mac hardware — fast unified memory, powerful GPUs and Neural Engines, and energy-efficient Apple Silicon — into one cluster you can run heavy workloads on.</p> | |
| 366 | + <p class="lead" style="margin-top:12px">The fleet spans {esc(chips)} — from top-tier Ultra Studios for training down to efficient minis and Airs for always-on services.</p> | |
| 367 | + <div class="row-btn" style="margin-top:24px"><a class="btn p" href="/contact">Get in touch {icon('arrow',18)}</a><a class="btn g" href="/nodes">See all nodes</a></div> | |
| 368 | + </div> | |
| 369 | + <div class="mini"> | |
| 370 | + {stat("cpu",t["cpu"],"CPU cores")} | |
| 371 | + {stat("chip",t["ram"],"GB RAM")} | |
| 372 | + {stat("gpu",t["gpu"],"GPU cores")} | |
| 373 | + {stat("command",t["ne"],"Neural cores")} | |
| 374 | + {stat("server",t["n"],"Nodes")} | |
| 375 | + {stat("hd",gb_to_tb(t["store"]),"SSD")} | |
| 376 | + </div> | |
| 377 | + </div> | |
| 378 | +</div> | |
| 379 | + | |
| 380 | +<div class="section center"> | |
| 381 | + <div class="eyebrow"><span class="kick">Principles</span></div> | |
| 382 | + <h2 style="margin-top:14px">Why self-hosted</h2> | |
| 383 | + <div class="cards" style="text-align:left"> | |
| 384 | + <div class="fcard"><div class="ic">{icon('lock')}</div><h3>Yours, end to end</h3><p>Dedicated hardware you control — no noisy neighbours, no surprise migrations.</p></div> | |
| 385 | + <div class="fcard"><div class="ic">{icon('zap')}</div><h3>Efficient & fast</h3><p>Apple Silicon delivers huge unified memory and performance per watt.</p></div> | |
| 386 | + <div class="fcard"><div class="ic">{icon('gauge')}</div><h3>Transparent</h3><p>Every node has a live status page — see real CPU, memory and disk anytime.</p></div> | |
| 387 | + </div> | |
| 388 | +</div> | |
| 389 | + | |
| 390 | +<div class="section" style="padding-top:0">{contact_band()}</div> | |
| 391 | +</div>""" | |
| 392 | + return shell("About — MacLustr", "about", body, "About the MacLustr self-hosted Apple Silicon cluster.") | |
| 393 | + | |
| 394 | +def page_contact(): | |
| 395 | + body = f"""<div class="wrap"> | |
| 396 | +<div class="section" style="padding-bottom:10px;text-align:center"> | |
| 397 | + <div class="eyebrow"><span class="kick">{icon('mail',16)} Contact</span></div> | |
| 398 | + <h2 style="margin-top:14px">Let's find the right node for you</h2> | |
| 399 | + <p class="lead mx" style="margin-top:12px">Need compute, hosting or private cloud on one of our nodes? Send a short note about your workload and we'll get back to you.</p> | |
| 400 | +</div> | |
| 401 | +<div class="section" style="padding-top:10px">{contact_band()}</div> | |
| 402 | +<div class="section center" style="padding-top:0"> | |
| 403 | + <h2 style="font-size:24px">What to include</h2> | |
| 404 | + <div class="cards" style="text-align:left;margin-top:24px"> | |
| 405 | + <div class="fcard"><div class="ic">{icon('gauge')}</div><h3>Your workload</h3><p>What you want to run — compute job, a service to host, or a private cloud space.</p></div> | |
| 406 | + <div class="fcard"><div class="ic">{icon('chip')}</div><h3>Rough resources</h3><p>Cores, memory, GPU, storage — even a ballpark helps us match a tier.</p></div> | |
| 407 | + <div class="fcard"><div class="ic">{icon('db')}</div><h3>Duration</h3><p>One-off job or always-on? Let us know the timeframe.</p></div> | |
| 408 | + </div> | |
| 409 | +</div> | |
| 410 | +</div>""" | |
| 411 | + return shell("Contact — MacLustr", "contact", body, f"Contact MacLustr at {CONTACT} for compute, hosting and cloud.") | |
| 412 | + | |
| 413 | +# ---- pricing (USD). MacLustr rate ~= 10% below a comparable AWS EC2 Mac Dedicated Host. ---- | |
| 414 | +# rate = MacLustr $/h ; AWS est. $/h = rate / 0.9 ; day = rate*24 ; month = rate*730 | |
| 415 | +RATES = { | |
| 416 | + "m3u96a": 8.44, "m3u96b": 8.44, "m2u64": 7.47, | |
| 417 | + "m4m64a": 7.47, "m4m64b": 7.47, "m4bp48": 6.72, "m4m36": 5.41, "m4bp36": 5.41, | |
| 418 | + "m2m32": 1.40, "m2m32b": 1.40, "m2m32c": 1.40, "m1m32": 1.35, | |
| 419 | + "m4mc": 0.77, "m2m16": 0.99, "m4ma": 1.11, "m4mb": 0.96, | |
| 420 | + "m3ba24": 0.59, "m3ba16": 0.51, "m2m8a": 0.43, "m2m8b": 0.43, | |
| 421 | +} | |
| 422 | +PLANS = [ | |
| 423 | + ("ULTRA", "#5B21B6", "AI & heavy compute", 7.50, True, | |
| 424 | + "M-series Ultra & M4 Max with 64–96 GB unified memory. Built for model training, large-context inference and the heaviest jobs.", | |
| 425 | + ["64–96 GB unified memory", "Up to 60 GPU + 32 Neural cores", "Dedicated, isolated host", "24h minimum"]), | |
| 426 | + ("MAX", "#7C3AED", "High-throughput power", 5.50, False, | |
| 427 | + "M4 Max nodes with 36–48 GB. Strong GPU and compute for demanding production workloads.", | |
| 428 | + ["36–48 GB unified memory", "40 GPU cores · 16 CPU cores", "Latest-gen Apple Silicon", "Dedicated host"]), | |
| 429 | + ("STUDIO", "#2563EB", "Dedicated mid-tier", 1.35, False, | |
| 430 | + "M2 / M1 Max Studios with ~32 GB. Reliable dedicated power at an accessible price.", | |
| 431 | + ["30–32 GB unified memory", "24–30 GPU cores", "Always-on dedicated node", "Great price/perf"]), | |
| 432 | + ("WORKER", "#0891B2", "CI · hosting · light AI", 0.43, False, | |
| 433 | + "Mac mini & MacBook Air (M-series Pro/Base). Perfect for CI/CD, web hosting, agents and light inference.", | |
| 434 | + ["8–24 GB unified memory", "Mac mini / MacBook Air", "Web & API hosting, CI runners", "Pay only for what you run"]), | |
| 435 | +] | |
| 436 | + | |
| 437 | +def fmt(v): return f"${v:,.2f}" | |
| 438 | +def fmt0(v): return f"${v:,.0f}" | |
| 439 | + | |
| 440 | +def plan_card(p): | |
| 441 | + tier, color, tag, frm, hot, desc, feats = p | |
| 442 | + feats_html = "".join(f"<li>{icon('check',16)} {f}</li>" for f in feats) | |
| 443 | + return (f'<div class="plan{" hot" if hot else ""}">' | |
| 444 | + f'<span class="tg" style="background:{color}">{tier}</span>' | |
| 445 | + f'<h3>{tag}</h3>' | |
| 446 | + f'<div class="from">from</div>' | |
| 447 | + f'<div class="price">{fmt(frm)}<small>/hour</small></div>' | |
| 448 | + f'<p>{desc}</p><ul>{feats_html}</ul>' | |
| 449 | + f'<a class="cta2" href="/contact">Request access</a></div>') | |
| 450 | + | |
| 451 | +def pricing_rows(): | |
| 452 | + rows = "" | |
| 453 | + order = sorted(NODES, key=lambda n: -RATES.get(n["slug"], 0)) | |
| 454 | + for n in order: | |
| 455 | + h = RATES.get(n["slug"], 0) | |
| 456 | + aws = h / 0.9 | |
| 457 | + day = h * 24 | |
| 458 | + month = h * 730 | |
| 459 | + spec = f'{n["ncpu"]}C / {n["gpu"]}G / {n["mem"]}GB' | |
| 460 | + rows += (f'<tr><td class="node-nm">{esc(n["alias"])}</td>' | |
| 461 | + f'<td><span class="tg" style="background:{TCOLOR.get(n["tier"],"#666")}">{esc(n["tier"])}</span></td>' | |
| 462 | + f'<td class="spec">{esc(spec)}</td>' | |
| 463 | + f'<td class="aws">{fmt(aws)}</td>' | |
| 464 | + f'<td class="ml">{fmt(h)}</td>' | |
| 465 | + f'<td class="num">{fmt(day)}</td>' | |
| 466 | + f'<td class="num">{fmt0(month)}/mo</td></tr>') | |
| 467 | + return rows | |
| 468 | + | |
| 469 | +def whole_cluster(): | |
| 470 | + compute_h = sum(RATES.values()) | |
| 471 | + storage_mo = 35000 * 0.05 + 45000 * 0.02 # 35 TB SSD + 45 TB HDD external | |
| 472 | + total_h = compute_h + storage_mo / 730 | |
| 473 | + day = total_h * 24 | |
| 474 | + month = total_h * 730 | |
| 475 | + year = total_h * 8760 | |
| 476 | + t = totals() | |
| 477 | + def cell(label, val, per, hl=False): | |
| 478 | + return (f'<div class="{ "hl" if hl else "" }"><div class="l">{label}</div>' | |
| 479 | + f'<div class="n">{val}</div><div class="p">{per}</div></div>') | |
| 480 | + return f"""<div class="clusterbox"> | |
| 481 | + <span class="kick">{icon('layers',16)} Whole infrastructure</span> | |
| 482 | + <h2>Rent the entire cluster</h2> | |
| 483 | + <p class="sub2">Every node and all external storage, exclusively yours — a turn-key private Apple Silicon cloud.</p> | |
| 484 | + <div class="cl-prices"> | |
| 485 | + {cell("Hourly", fmt(total_h), "per hour")} | |
| 486 | + {cell("Daily", fmt0(day), "24 hours")} | |
| 487 | + {cell("Monthly", fmt0(month), "730 hours", hl=True)} | |
| 488 | + {cell("Yearly", fmt0(year), "8,760 hours")} | |
| 489 | + </div> | |
| 490 | + <div class="cl-incl"><span>{t['n']} nodes</span><span>{t['cpu']} CPU cores</span><span>{t['ram']} GB RAM</span><span>{t['gpu']} GPU cores</span><span>{t['ne']} Neural cores</span><span>35 TB SSD + 45 TB HDD external</span></div> | |
| 491 | + <a class="btn p" href="/contact">Reserve the full cluster {icon('arrow',18)}</a> | |
| 492 | + <p class="fine">Includes every node (with their internal SSDs) plus the full 80 TB external storage pool — 35 TB SSD and 45 TB HDD. Annual and long-term commitments qualify for a custom discount.</p> | |
| 493 | +</div>""" | |
| 494 | + | |
| 495 | +def page_pricing(): | |
| 496 | + body = f"""<div class="wrap"> | |
| 497 | +<div class="section" style="padding-bottom:6px;text-align:center"> | |
| 498 | + <div class="eyebrow"><span class="kick">{icon('gauge',16)} Pricing</span></div> | |
| 499 | + <h2 style="margin-top:14px">Dedicated Apple Silicon, <span class="grad">~10% below AWS</span></h2> | |
| 500 | + <p class="lead mx" style="margin-top:12px">Transparent per-node pricing, benchmarked against AWS EC2 Mac Dedicated Hosts (24-hour minimum). Rent by the hour, day, month or year.</p> | |
| 501 | +</div> | |
| 502 | + | |
| 503 | +{whole_cluster()} | |
| 504 | + | |
| 505 | +<div class="section" style="padding-top:48px"><div class="center"><div class="eyebrow"><span class="kick">{icon('server',16)} By the node</span></div> | |
| 506 | +<h2 style="margin-top:14px">Or pick a plan</h2></div> | |
| 507 | +<div class="plans">{"".join(plan_card(p) for p in PLANS)}</div></div> | |
| 508 | + | |
| 509 | +<div class="section" style="padding-top:36px"> | |
| 510 | + <div class="center"><div class="eyebrow"><span class="kick">{icon('layers',16)} Full price list</span></div> | |
| 511 | + <h2 style="margin-top:14px">Every node, per hour</h2> | |
| 512 | + <p class="lead mx" style="margin-top:12px">Hourly rate, plus 24-hour and 730-hour (monthly) totals. AWS column shows a comparable EC2 Mac Dedicated Host estimate.</p></div> | |
| 513 | + <div class="ptable"><div class="pscroll"><table class="pg"> | |
| 514 | + <thead><tr><th>Node</th><th>Tier</th><th>Specs (CPU/GPU/RAM)</th><th>AWS est.</th><th>MacLustr /h</th><th>/day (24h)</th><th>/month (730h)</th></tr></thead> | |
| 515 | + <tbody>{pricing_rows()}</tbody> | |
| 516 | + </table></div></div> | |
| 517 | + <p class="note">Prices in USD. Specs shown as CPU cores / GPU cores / unified memory. <b>MacLustr /h</b> is our rate; <b>AWS est.</b> is a comparable AWS EC2 Mac Dedicated Host (on-demand, 24h minimum) — MacLustr runs about 10% below. Monthly assumes 730 hours of continuous use; short jobs are billed hourly. Custom and long-term plans available on request.</p> | |
| 518 | +</div> | |
| 519 | + | |
| 520 | +<div class="section" style="padding-top:10px"> | |
| 521 | + <div class="center"><div class="eyebrow"><span class="kick">{icon('hd',16)} Storage add-ons</span></div> | |
| 522 | + <h2 style="margin-top:14px">Need more space?</h2> | |
| 523 | + <p class="lead mx" style="margin-top:12px">Attach external storage to any node — fast SSD for hot data, high-capacity HDD for archives and backups. Pay per gigabyte, per month.</p></div> | |
| 524 | + <div class="cards" style="grid-template-columns:1fr 1fr"> | |
| 525 | + <div class="fcard"><div class="ic">{icon('hd')}</div> | |
| 526 | + <h3>External SSD</h3> | |
| 527 | + <p>Fast SSD storage for active datasets, databases and model weights.</p> | |
| 528 | + <div style="font-size:36px;font-weight:840;letter-spacing:-.03em;margin-top:16px">$0.05<small style="font-size:15px;color:var(--mut);font-weight:700"> / GB · month</small></div> | |
| 529 | + <ul><li>{icon('check',16)} ≈ $50 per TB · month</li><li>{icon('check',16)} Up to 35 TB available</li><li>{icon('check',16)} High throughput, low latency</li></ul> | |
| 530 | + </div> | |
| 531 | + <div class="fcard"><div class="ic">{icon('db')}</div> | |
| 532 | + <h3>External HDD</h3> | |
| 533 | + <p>High-capacity spinning disk for archives, backups and cold storage.</p> | |
| 534 | + <div style="font-size:36px;font-weight:840;letter-spacing:-.03em;margin-top:16px">$0.02<small style="font-size:15px;color:var(--mut);font-weight:700"> / GB · month</small></div> | |
| 535 | + <ul><li>{icon('check',16)} ≈ $20 per TB · month</li><li>{icon('check',16)} Up to 45 TB available</li><li>{icon('check',16)} Ideal for backups & archives</li></ul> | |
| 536 | + </div> | |
| 537 | + </div> | |
| 538 | + <p class="note">Storage add-ons are billed per GB-month and attach to any rented node. Total external pools available: <b>35 TB SSD</b> and <b>45 TB HDD</b>. Larger or dedicated volumes available on request.</p> | |
| 539 | +</div> | |
| 540 | + | |
| 541 | +<div class="section" style="padding-top:0">{contact_band()}</div> | |
| 542 | +</div>""" | |
| 543 | + return shell("Pricing — MacLustr", "pricing", body, | |
| 544 | + "MacLustr pricing: dedicated Apple Silicon nodes ~10% below comparable AWS EC2 Mac Dedicated Hosts.") | |
| 545 | + | |
| 546 | +def contact_band(): | |
| 547 | + return f"""<div class="band"> | |
| 548 | + <h2>Need compute, hosting or cloud?</h2> | |
| 549 | + <p>If you need any kind of computation, hosting or private cloud on one of our nodes, write to us — describe your workload and we'll find the right node.</p> | |
| 550 | + <a class="mail" href="mailto:{CONTACT}?subject=MacLustr%20%E2%80%94%20compute%2Fhosting%2Fcloud%20request">{icon('mail',20)}{CONTACT}</a> | |
| 551 | + <div class="chips"><span>Compute jobs</span><span>ML training / inference</span><span>Web & API hosting</span><span>Private cloud</span><span>macOS CI</span></div> | |
| 552 | +</div>""" | |
| 553 | + | |
| 554 | +ROUTES = {"": page_home, "nodes": page_nodes, "services": page_services, | |
| 555 | + "pricing": page_pricing, "about": page_about, "contact": page_contact} | |
| 556 | + | |
| 557 | +LOGO_PATH = os.path.join(HERE, "logo.svg") | |
| 558 | +LOGO = open(LOGO_PATH).read() if os.path.exists(LOGO_PATH) else "<svg/>" | |
| 559 | + | |
| 560 | +class H(BaseHTTPRequestHandler): | |
| 561 | + def log_message(self, *a): pass | |
| 562 | + def _send(self, code, body, ctype="text/html; charset=utf-8"): | |
| 563 | + b = body.encode() if isinstance(body, str) else body | |
| 564 | + self.send_response(code) | |
| 565 | + self.send_header("Content-Type", ctype) | |
| 566 | + self.send_header("Content-Length", str(len(b))) | |
| 567 | + self.send_header("Cache-Control", "no-store") | |
| 568 | + self.end_headers() | |
| 569 | + try: self.wfile.write(b) | |
| 570 | + except (BrokenPipeError, ConnectionResetError): pass | |
| 571 | + def do_GET(self): | |
| 572 | + path = urlparse(self.path).path.strip("/") | |
| 573 | + if path in ("health", "healthz"): return self._send(200, "ok", "text/plain") | |
| 574 | + if path == "logo.svg": return self._send(200, LOGO, "image/svg+xml") | |
| 575 | + if path in ROUTES: return self._send(200, ROUTES[path]()) | |
| 576 | + nf = '<div class="wrap section center"><h2>Page not found</h2><p class="lead mx" style="margin-top:12px">Sorry, that page does not exist.</p><div class="row-btn" style="justify-content:center;margin-top:22px"><a class="btn p" href="/">Back home</a></div></div>' | |
| 577 | + return self._send(404, shell("Not found — MacLustr", "", nf)) | |
| 578 | + | |
| 579 | +if __name__ == "__main__": | |
| 580 | + srv = ThreadingHTTPServer(("0.0.0.0", PORT), H) | |
| 581 | + print(f"MacLustr landing on :{PORT} ({len(NODES)} nodes)") | |
| 582 | + srv.serve_forever() | |
added
logo.svg
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> | |
| 3 | + <defs> | |
| 4 | + <linearGradient id="nodeGrad" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| 5 | + <stop offset="0%" style="stop-color:#7C3AED" /> | |
| 6 | + <stop offset="100%" style="stop-color:#A855F7" /> | |
| 7 | + </linearGradient> | |
| 8 | + <linearGradient id="blueGrad" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| 9 | + <stop offset="0%" style="stop-color:#3B82F6" /> | |
| 10 | + <stop offset="100%" style="stop-color:#06B6D4" /> | |
| 11 | + </linearGradient> | |
| 12 | + <linearGradient id="lineGrad1" x1="0%" y1="0%" x2="100%" y2="0%"> | |
| 13 | + <stop offset="0%" style="stop-color:#7C3AED;stop-opacity:0.5" /> | |
| 14 | + <stop offset="100%" style="stop-color:#3B82F6;stop-opacity:0.5" /> | |
| 15 | + </linearGradient> | |
| 16 | + <linearGradient id="lineGrad2" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| 17 | + <stop offset="0%" style="stop-color:#3B82F6;stop-opacity:0.4" /> | |
| 18 | + <stop offset="100%" style="stop-color:#06B6D4;stop-opacity:0.4" /> | |
| 19 | + </linearGradient> | |
| 20 | + <linearGradient id="centerGrad" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| 21 | + <stop offset="0%" style="stop-color:#7C3AED" /> | |
| 22 | + <stop offset="50%" style="stop-color:#3B82F6" /> | |
| 23 | + <stop offset="100%" style="stop-color:#06B6D4" /> | |
| 24 | + </linearGradient> | |
| 25 | + </defs> | |
| 26 | + | |
| 27 | + <!-- White background --> | |
| 28 | + <rect width="1024" height="1024" rx="220" fill="#FFFFFF" /> | |
| 29 | + <rect x="6" y="6" width="1012" height="1012" rx="217" fill="none" stroke="#E5E7EB" stroke-width="2" /> | |
| 30 | + | |
| 31 | + <!-- Outer ring connections --> | |
| 32 | + <line x1="512" y1="200" x2="730" y2="300" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 33 | + <line x1="730" y1="300" x2="812" y2="512" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 34 | + <line x1="812" y1="512" x2="730" y2="724" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 35 | + <line x1="730" y1="724" x2="512" y2="824" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 36 | + <line x1="512" y1="824" x2="294" y2="724" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 37 | + <line x1="294" y1="724" x2="212" y2="512" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 38 | + <line x1="212" y1="512" x2="294" y2="300" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 39 | + <line x1="294" y1="300" x2="512" y2="200" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" /> | |
| 40 | + | |
| 41 | + <!-- Radial connections to center --> | |
| 42 | + <line x1="512" y1="200" x2="512" y2="420" stroke="url(#lineGrad1)" stroke-width="5" stroke-linecap="round" /> | |
| 43 | + <line x1="730" y1="300" x2="580" y2="445" stroke="url(#lineGrad2)" stroke-width="4" stroke-linecap="round" /> | |
| 44 | + <line x1="812" y1="512" x2="604" y2="512" stroke="url(#lineGrad1)" stroke-width="5" stroke-linecap="round" /> | |
| 45 | + <line x1="730" y1="724" x2="580" y2="580" stroke="url(#lineGrad2)" stroke-width="4" stroke-linecap="round" /> | |
| 46 | + <line x1="512" y1="824" x2="512" y2="604" stroke="url(#lineGrad1)" stroke-width="5" stroke-linecap="round" /> | |
| 47 | + <line x1="294" y1="724" x2="444" y2="580" stroke="url(#lineGrad2)" stroke-width="4" stroke-linecap="round" /> | |
| 48 | + <line x1="212" y1="512" x2="420" y2="512" stroke="url(#lineGrad1)" stroke-width="5" stroke-linecap="round" /> | |
| 49 | + <line x1="294" y1="300" x2="444" y2="445" stroke="url(#lineGrad2)" stroke-width="4" stroke-linecap="round" /> | |
| 50 | + | |
| 51 | + <!-- Center node --> | |
| 52 | + <circle cx="512" cy="512" r="95" fill="url(#centerGrad)" /> | |
| 53 | + <circle cx="512" cy="512" r="72" fill="white" fill-opacity="0.2" /> | |
| 54 | + <circle cx="512" cy="512" r="44" fill="white" fill-opacity="0.25" /> | |
| 55 | + <!-- Center icon: cluster symbol --> | |
| 56 | + <circle cx="512" cy="500" r="8" fill="white" fill-opacity="0.9" /> | |
| 57 | + <circle cx="496" cy="524" r="6" fill="white" fill-opacity="0.7" /> | |
| 58 | + <circle cx="528" cy="524" r="6" fill="white" fill-opacity="0.7" /> | |
| 59 | + <line x1="512" y1="500" x2="496" y2="524" stroke="white" stroke-width="2" stroke-opacity="0.6" /> | |
| 60 | + <line x1="512" y1="500" x2="528" y2="524" stroke="white" stroke-width="2" stroke-opacity="0.6" /> | |
| 61 | + <line x1="496" y1="524" x2="528" y2="524" stroke="white" stroke-width="2" stroke-opacity="0.6" /> | |
| 62 | + | |
| 63 | + <!-- Outer nodes: Ultra (purple, larger) and Max (blue, smaller) --> | |
| 64 | + <!-- Top - Ultra --> | |
| 65 | + <circle cx="512" cy="200" r="48" fill="url(#nodeGrad)" /> | |
| 66 | + <circle cx="512" cy="200" r="24" fill="white" fill-opacity="0.25" /> | |
| 67 | + | |
| 68 | + <!-- Top-right - Max --> | |
| 69 | + <circle cx="730" cy="300" r="40" fill="url(#blueGrad)" /> | |
| 70 | + <circle cx="730" cy="300" r="20" fill="white" fill-opacity="0.25" /> | |
| 71 | + | |
| 72 | + <!-- Right - Ultra --> | |
| 73 | + <circle cx="812" cy="512" r="48" fill="url(#nodeGrad)" /> | |
| 74 | + <circle cx="812" cy="512" r="24" fill="white" fill-opacity="0.25" /> | |
| 75 | + | |
| 76 | + <!-- Bottom-right - Max --> | |
| 77 | + <circle cx="730" cy="724" r="40" fill="url(#blueGrad)" /> | |
| 78 | + <circle cx="730" cy="724" r="20" fill="white" fill-opacity="0.25" /> | |
| 79 | + | |
| 80 | + <!-- Bottom - Ultra --> | |
| 81 | + <circle cx="512" cy="824" r="48" fill="url(#nodeGrad)" /> | |
| 82 | + <circle cx="512" cy="824" r="24" fill="white" fill-opacity="0.25" /> | |
| 83 | + | |
| 84 | + <!-- Bottom-left - Max --> | |
| 85 | + <circle cx="294" cy="724" r="40" fill="url(#blueGrad)" /> | |
| 86 | + <circle cx="294" cy="724" r="20" fill="white" fill-opacity="0.25" /> | |
| 87 | + | |
| 88 | + <!-- Left - Ultra --> | |
| 89 | + <circle cx="212" cy="512" r="48" fill="url(#nodeGrad)" /> | |
| 90 | + <circle cx="212" cy="512" r="24" fill="white" fill-opacity="0.25" /> | |
| 91 | + | |
| 92 | + <!-- Top-left - Max --> | |
| 93 | + <circle cx="294" cy="300" r="40" fill="url(#blueGrad)" /> | |
| 94 | + <circle cx="294" cy="300" r="20" fill="white" fill-opacity="0.25" /> | |
| 95 | + | |
| 96 | + <!-- Small activity dots on lines --> | |
| 97 | + <circle cx="512" cy="310" r="5" fill="#7C3AED" opacity="0.7" /> | |
| 98 | + <circle cx="655" cy="372" r="4" fill="#3B82F6" opacity="0.6" /> | |
| 99 | + <circle cx="710" cy="512" r="5" fill="#7C3AED" opacity="0.7" /> | |
| 100 | + <circle cx="655" cy="652" r="4" fill="#3B82F6" opacity="0.6" /> | |
| 101 | + <circle cx="512" cy="714" r="5" fill="#7C3AED" opacity="0.7" /> | |
| 102 | + <circle cx="369" cy="652" r="4" fill="#3B82F6" opacity="0.6" /> | |
| 103 | + <circle cx="316" cy="512" r="5" fill="#7C3AED" opacity="0.7" /> | |
| 104 | + <circle cx="369" cy="372" r="4" fill="#3B82F6" opacity="0.6" /> | |
| 105 | +</svg> | |
added
maintenance.json
+606 −0
@@ -0,0 +1,606 @@ | ||
| 1 | +{ | |
| 2 | + "m4ma": { | |
| 3 | + "tasks": { | |
| 4 | + "0": { | |
| 5 | + "done": false, | |
| 6 | + "date": "", | |
| 7 | + "note": "" | |
| 8 | + }, | |
| 9 | + "1": { | |
| 10 | + "done": true, | |
| 11 | + "date": "", | |
| 12 | + "note": "SPB" | |
| 13 | + }, | |
| 14 | + "2": { | |
| 15 | + "done": false, | |
| 16 | + "date": "", | |
| 17 | + "note": "" | |
| 18 | + }, | |
| 19 | + "3": { | |
| 20 | + "done": false, | |
| 21 | + "date": "", | |
| 22 | + "note": "" | |
| 23 | + }, | |
| 24 | + "4": { | |
| 25 | + "done": true, | |
| 26 | + "date": "", | |
| 27 | + "note": "SPB" | |
| 28 | + }, | |
| 29 | + "5": { | |
| 30 | + "done": false, | |
| 31 | + "date": "", | |
| 32 | + "note": "" | |
| 33 | + }, | |
| 34 | + "6": { | |
| 35 | + "done": false, | |
| 36 | + "date": "", | |
| 37 | + "note": "" | |
| 38 | + }, | |
| 39 | + "7": { | |
| 40 | + "done": false, | |
| 41 | + "date": "", | |
| 42 | + "note": "" | |
| 43 | + }, | |
| 44 | + "8": { | |
| 45 | + "done": false, | |
| 46 | + "date": "", | |
| 47 | + "note": "" | |
| 48 | + }, | |
| 49 | + "9": { | |
| 50 | + "done": false, | |
| 51 | + "date": "", | |
| 52 | + "note": "" | |
| 53 | + } | |
| 54 | + }, | |
| 55 | + "next": "", | |
| 56 | + "log": "", | |
| 57 | + "condition": "", | |
| 58 | + "inspected_by": "", | |
| 59 | + "reports": [ | |
| 60 | + { | |
| 61 | + "at": "2026-06-16 13:55", | |
| 62 | + "by": "", | |
| 63 | + "condition": "", | |
| 64 | + "next": "", | |
| 65 | + "log": "", | |
| 66 | + "items": [ | |
| 67 | + { | |
| 68 | + "title": "macOS updated", | |
| 69 | + "status": "done", | |
| 70 | + "date": "", | |
| 71 | + "by": "", | |
| 72 | + "note": "SPB" | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "title": "SSH / network connectivity tested", | |
| 76 | + "status": "done", | |
| 77 | + "date": "", | |
| 78 | + "by": "", | |
| 79 | + "note": "SPB" | |
| 80 | + } | |
| 81 | + ] | |
| 82 | + } | |
| 83 | + ], | |
| 84 | + "saved_at": "2026-06-16 13:55" | |
| 85 | + }, | |
| 86 | + "m4mb": { | |
| 87 | + "tasks": { | |
| 88 | + "0": { | |
| 89 | + "status": "", | |
| 90 | + "done": false, | |
| 91 | + "date": "", | |
| 92 | + "by": "", | |
| 93 | + "note": "" | |
| 94 | + }, | |
| 95 | + "1": { | |
| 96 | + "status": "done", | |
| 97 | + "done": true, | |
| 98 | + "date": "", | |
| 99 | + "by": "SPB", | |
| 100 | + "note": "" | |
| 101 | + }, | |
| 102 | + "2": { | |
| 103 | + "status": "", | |
| 104 | + "done": false, | |
| 105 | + "date": "", | |
| 106 | + "by": "", | |
| 107 | + "note": "" | |
| 108 | + }, | |
| 109 | + "3": { | |
| 110 | + "status": "", | |
| 111 | + "done": false, | |
| 112 | + "date": "", | |
| 113 | + "by": "", | |
| 114 | + "note": "" | |
| 115 | + }, | |
| 116 | + "4": { | |
| 117 | + "status": "done", | |
| 118 | + "done": true, | |
| 119 | + "date": "", | |
| 120 | + "by": "SPB", | |
| 121 | + "note": "" | |
| 122 | + }, | |
| 123 | + "5": { | |
| 124 | + "status": "", | |
| 125 | + "done": false, | |
| 126 | + "date": "", | |
| 127 | + "by": "", | |
| 128 | + "note": "" | |
| 129 | + }, | |
| 130 | + "6": { | |
| 131 | + "status": "", | |
| 132 | + "done": false, | |
| 133 | + "date": "", | |
| 134 | + "by": "", | |
| 135 | + "note": "" | |
| 136 | + }, | |
| 137 | + "7": { | |
| 138 | + "status": "", | |
| 139 | + "done": false, | |
| 140 | + "date": "", | |
| 141 | + "by": "", | |
| 142 | + "note": "" | |
| 143 | + }, | |
| 144 | + "8": { | |
| 145 | + "status": "", | |
| 146 | + "done": false, | |
| 147 | + "date": "", | |
| 148 | + "by": "", | |
| 149 | + "note": "" | |
| 150 | + }, | |
| 151 | + "9": { | |
| 152 | + "status": "", | |
| 153 | + "done": false, | |
| 154 | + "date": "", | |
| 155 | + "by": "", | |
| 156 | + "note": "" | |
| 157 | + } | |
| 158 | + }, | |
| 159 | + "next": "", | |
| 160 | + "log": "", | |
| 161 | + "condition": "", | |
| 162 | + "inspected_by": "", | |
| 163 | + "reports": [ | |
| 164 | + { | |
| 165 | + "at": "2026-06-16 13:57", | |
| 166 | + "by": "", | |
| 167 | + "condition": "", | |
| 168 | + "next": "", | |
| 169 | + "log": "", | |
| 170 | + "items": [ | |
| 171 | + { | |
| 172 | + "title": "macOS updated", | |
| 173 | + "status": "done", | |
| 174 | + "date": "", | |
| 175 | + "by": "SPB", | |
| 176 | + "note": "" | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + "title": "SSH / network connectivity tested", | |
| 180 | + "status": "done", | |
| 181 | + "date": "", | |
| 182 | + "by": "SPB", | |
| 183 | + "note": "" | |
| 184 | + } | |
| 185 | + ] | |
| 186 | + } | |
| 187 | + ], | |
| 188 | + "saved_at": "2026-06-16 13:57" | |
| 189 | + }, | |
| 190 | + "m4mc": { | |
| 191 | + "tasks": { | |
| 192 | + "0": { | |
| 193 | + "status": "", | |
| 194 | + "done": false, | |
| 195 | + "date": "", | |
| 196 | + "by": "", | |
| 197 | + "note": "" | |
| 198 | + }, | |
| 199 | + "1": { | |
| 200 | + "status": "done", | |
| 201 | + "done": true, | |
| 202 | + "date": "", | |
| 203 | + "by": "SPB", | |
| 204 | + "note": "" | |
| 205 | + }, | |
| 206 | + "2": { | |
| 207 | + "status": "", | |
| 208 | + "done": false, | |
| 209 | + "date": "", | |
| 210 | + "by": "", | |
| 211 | + "note": "" | |
| 212 | + }, | |
| 213 | + "3": { | |
| 214 | + "status": "", | |
| 215 | + "done": false, | |
| 216 | + "date": "", | |
| 217 | + "by": "", | |
| 218 | + "note": "" | |
| 219 | + }, | |
| 220 | + "4": { | |
| 221 | + "status": "done", | |
| 222 | + "done": true, | |
| 223 | + "date": "", | |
| 224 | + "by": "SPB", | |
| 225 | + "note": "" | |
| 226 | + }, | |
| 227 | + "5": { | |
| 228 | + "status": "", | |
| 229 | + "done": false, | |
| 230 | + "date": "", | |
| 231 | + "by": "", | |
| 232 | + "note": "" | |
| 233 | + }, | |
| 234 | + "6": { | |
| 235 | + "status": "", | |
| 236 | + "done": false, | |
| 237 | + "date": "", | |
| 238 | + "by": "", | |
| 239 | + "note": "" | |
| 240 | + }, | |
| 241 | + "7": { | |
| 242 | + "status": "", | |
| 243 | + "done": false, | |
| 244 | + "date": "", | |
| 245 | + "by": "", | |
| 246 | + "note": "" | |
| 247 | + }, | |
| 248 | + "8": { | |
| 249 | + "status": "", | |
| 250 | + "done": false, | |
| 251 | + "date": "", | |
| 252 | + "by": "", | |
| 253 | + "note": "" | |
| 254 | + }, | |
| 255 | + "9": { | |
| 256 | + "status": "", | |
| 257 | + "done": false, | |
| 258 | + "date": "", | |
| 259 | + "by": "", | |
| 260 | + "note": "" | |
| 261 | + } | |
| 262 | + }, | |
| 263 | + "next": "", | |
| 264 | + "log": "", | |
| 265 | + "condition": "", | |
| 266 | + "inspected_by": "", | |
| 267 | + "reports": [ | |
| 268 | + { | |
| 269 | + "at": "2026-06-16 13:58", | |
| 270 | + "by": "", | |
| 271 | + "condition": "", | |
| 272 | + "next": "", | |
| 273 | + "log": "", | |
| 274 | + "items": [ | |
| 275 | + { | |
| 276 | + "title": "macOS updated", | |
| 277 | + "status": "done", | |
| 278 | + "date": "", | |
| 279 | + "by": "SPB", | |
| 280 | + "note": "" | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "title": "SSH / network connectivity tested", | |
| 284 | + "status": "done", | |
| 285 | + "date": "", | |
| 286 | + "by": "SPB", | |
| 287 | + "note": "" | |
| 288 | + } | |
| 289 | + ] | |
| 290 | + } | |
| 291 | + ], | |
| 292 | + "saved_at": "2026-06-16 13:58" | |
| 293 | + }, | |
| 294 | + "m4m64a": { | |
| 295 | + "tasks": { | |
| 296 | + "0": { | |
| 297 | + "status": "", | |
| 298 | + "done": false, | |
| 299 | + "date": "", | |
| 300 | + "by": "", | |
| 301 | + "note": "" | |
| 302 | + }, | |
| 303 | + "1": { | |
| 304 | + "status": "done", | |
| 305 | + "done": true, | |
| 306 | + "date": "", | |
| 307 | + "by": "SPB", | |
| 308 | + "note": "" | |
| 309 | + }, | |
| 310 | + "2": { | |
| 311 | + "status": "", | |
| 312 | + "done": false, | |
| 313 | + "date": "", | |
| 314 | + "by": "", | |
| 315 | + "note": "" | |
| 316 | + }, | |
| 317 | + "3": { | |
| 318 | + "status": "", | |
| 319 | + "done": false, | |
| 320 | + "date": "", | |
| 321 | + "by": "", | |
| 322 | + "note": "" | |
| 323 | + }, | |
| 324 | + "4": { | |
| 325 | + "status": "done", | |
| 326 | + "done": true, | |
| 327 | + "date": "", | |
| 328 | + "by": "SPB", | |
| 329 | + "note": "" | |
| 330 | + }, | |
| 331 | + "5": { | |
| 332 | + "status": "", | |
| 333 | + "done": false, | |
| 334 | + "date": "", | |
| 335 | + "by": "", | |
| 336 | + "note": "" | |
| 337 | + }, | |
| 338 | + "6": { | |
| 339 | + "status": "", | |
| 340 | + "done": false, | |
| 341 | + "date": "", | |
| 342 | + "by": "", | |
| 343 | + "note": "" | |
| 344 | + }, | |
| 345 | + "7": { | |
| 346 | + "status": "", | |
| 347 | + "done": false, | |
| 348 | + "date": "", | |
| 349 | + "by": "", | |
| 350 | + "note": "" | |
| 351 | + }, | |
| 352 | + "8": { | |
| 353 | + "status": "", | |
| 354 | + "done": false, | |
| 355 | + "date": "", | |
| 356 | + "by": "", | |
| 357 | + "note": "" | |
| 358 | + }, | |
| 359 | + "9": { | |
| 360 | + "status": "", | |
| 361 | + "done": false, | |
| 362 | + "date": "", | |
| 363 | + "by": "", | |
| 364 | + "note": "" | |
| 365 | + } | |
| 366 | + }, | |
| 367 | + "next": "", | |
| 368 | + "log": "", | |
| 369 | + "condition": "", | |
| 370 | + "inspected_by": "", | |
| 371 | + "reports": [ | |
| 372 | + { | |
| 373 | + "at": "2026-06-16 14:14", | |
| 374 | + "by": "", | |
| 375 | + "condition": "", | |
| 376 | + "next": "", | |
| 377 | + "log": "", | |
| 378 | + "items": [ | |
| 379 | + { | |
| 380 | + "title": "macOS updated", | |
| 381 | + "status": "done", | |
| 382 | + "date": "", | |
| 383 | + "by": "SPB", | |
| 384 | + "note": "" | |
| 385 | + }, | |
| 386 | + { | |
| 387 | + "title": "SSH / network connectivity tested", | |
| 388 | + "status": "done", | |
| 389 | + "date": "", | |
| 390 | + "by": "SPB", | |
| 391 | + "note": "" | |
| 392 | + } | |
| 393 | + ] | |
| 394 | + } | |
| 395 | + ], | |
| 396 | + "saved_at": "2026-06-16 14:14" | |
| 397 | + }, | |
| 398 | + "m4m36": { | |
| 399 | + "tasks": { | |
| 400 | + "0": { | |
| 401 | + "status": "", | |
| 402 | + "done": false, | |
| 403 | + "date": "", | |
| 404 | + "by": "", | |
| 405 | + "note": "" | |
| 406 | + }, | |
| 407 | + "1": { | |
| 408 | + "status": "done", | |
| 409 | + "done": true, | |
| 410 | + "date": "", | |
| 411 | + "by": "SPB", | |
| 412 | + "note": "" | |
| 413 | + }, | |
| 414 | + "2": { | |
| 415 | + "status": "", | |
| 416 | + "done": false, | |
| 417 | + "date": "", | |
| 418 | + "by": "", | |
| 419 | + "note": "" | |
| 420 | + }, | |
| 421 | + "3": { | |
| 422 | + "status": "", | |
| 423 | + "done": false, | |
| 424 | + "date": "", | |
| 425 | + "by": "", | |
| 426 | + "note": "" | |
| 427 | + }, | |
| 428 | + "4": { | |
| 429 | + "status": "done", | |
| 430 | + "done": true, | |
| 431 | + "date": "", | |
| 432 | + "by": "SPB", | |
| 433 | + "note": "" | |
| 434 | + }, | |
| 435 | + "5": { | |
| 436 | + "status": "", | |
| 437 | + "done": false, | |
| 438 | + "date": "", | |
| 439 | + "by": "", | |
| 440 | + "note": "" | |
| 441 | + }, | |
| 442 | + "6": { | |
| 443 | + "status": "", | |
| 444 | + "done": false, | |
| 445 | + "date": "", | |
| 446 | + "by": "", | |
| 447 | + "note": "" | |
| 448 | + }, | |
| 449 | + "7": { | |
| 450 | + "status": "", | |
| 451 | + "done": false, | |
| 452 | + "date": "", | |
| 453 | + "by": "", | |
| 454 | + "note": "" | |
| 455 | + }, | |
| 456 | + "8": { | |
| 457 | + "status": "", | |
| 458 | + "done": false, | |
| 459 | + "date": "", | |
| 460 | + "by": "", | |
| 461 | + "note": "" | |
| 462 | + }, | |
| 463 | + "9": { | |
| 464 | + "status": "", | |
| 465 | + "done": false, | |
| 466 | + "date": "", | |
| 467 | + "by": "", | |
| 468 | + "note": "" | |
| 469 | + } | |
| 470 | + }, | |
| 471 | + "next": "", | |
| 472 | + "log": "", | |
| 473 | + "condition": "", | |
| 474 | + "inspected_by": "", | |
| 475 | + "reports": [ | |
| 476 | + { | |
| 477 | + "at": "2026-06-16 16:55", | |
| 478 | + "by": "", | |
| 479 | + "condition": "", | |
| 480 | + "next": "", | |
| 481 | + "log": "", | |
| 482 | + "items": [ | |
| 483 | + { | |
| 484 | + "title": "macOS updated", | |
| 485 | + "status": "done", | |
| 486 | + "date": "", | |
| 487 | + "by": "SPB", | |
| 488 | + "note": "" | |
| 489 | + }, | |
| 490 | + { | |
| 491 | + "title": "SSH / network connectivity tested", | |
| 492 | + "status": "done", | |
| 493 | + "date": "", | |
| 494 | + "by": "SPB", | |
| 495 | + "note": "" | |
| 496 | + } | |
| 497 | + ] | |
| 498 | + } | |
| 499 | + ], | |
| 500 | + "saved_at": "2026-06-16 16:55" | |
| 501 | + }, | |
| 502 | + "m2m32": { | |
| 503 | + "tasks": { | |
| 504 | + "0": { | |
| 505 | + "status": "", | |
| 506 | + "done": false, | |
| 507 | + "date": "", | |
| 508 | + "by": "", | |
| 509 | + "note": "" | |
| 510 | + }, | |
| 511 | + "1": { | |
| 512 | + "status": "done", | |
| 513 | + "done": true, | |
| 514 | + "date": "", | |
| 515 | + "by": "SPB", | |
| 516 | + "note": "" | |
| 517 | + }, | |
| 518 | + "2": { | |
| 519 | + "status": "", | |
| 520 | + "done": false, | |
| 521 | + "date": "", | |
| 522 | + "by": "", | |
| 523 | + "note": "" | |
| 524 | + }, | |
| 525 | + "3": { | |
| 526 | + "status": "", | |
| 527 | + "done": false, | |
| 528 | + "date": "", | |
| 529 | + "by": "", | |
| 530 | + "note": "" | |
| 531 | + }, | |
| 532 | + "4": { | |
| 533 | + "status": "done", | |
| 534 | + "done": true, | |
| 535 | + "date": "", | |
| 536 | + "by": "SPB", | |
| 537 | + "note": "" | |
| 538 | + }, | |
| 539 | + "5": { | |
| 540 | + "status": "", | |
| 541 | + "done": false, | |
| 542 | + "date": "", | |
| 543 | + "by": "", | |
| 544 | + "note": "" | |
| 545 | + }, | |
| 546 | + "6": { | |
| 547 | + "status": "", | |
| 548 | + "done": false, | |
| 549 | + "date": "", | |
| 550 | + "by": "", | |
| 551 | + "note": "" | |
| 552 | + }, | |
| 553 | + "7": { | |
| 554 | + "status": "", | |
| 555 | + "done": false, | |
| 556 | + "date": "", | |
| 557 | + "by": "", | |
| 558 | + "note": "" | |
| 559 | + }, | |
| 560 | + "8": { | |
| 561 | + "status": "", | |
| 562 | + "done": false, | |
| 563 | + "date": "", | |
| 564 | + "by": "", | |
| 565 | + "note": "" | |
| 566 | + }, | |
| 567 | + "9": { | |
| 568 | + "status": "", | |
| 569 | + "done": false, | |
| 570 | + "date": "", | |
| 571 | + "by": "", | |
| 572 | + "note": "" | |
| 573 | + } | |
| 574 | + }, | |
| 575 | + "next": "", | |
| 576 | + "log": "", | |
| 577 | + "condition": "", | |
| 578 | + "inspected_by": "", | |
| 579 | + "reports": [ | |
| 580 | + { | |
| 581 | + "at": "2026-06-16 16:58", | |
| 582 | + "by": "", | |
| 583 | + "condition": "", | |
| 584 | + "next": "", | |
| 585 | + "log": "", | |
| 586 | + "items": [ | |
| 587 | + { | |
| 588 | + "title": "macOS updated", | |
| 589 | + "status": "done", | |
| 590 | + "date": "", | |
| 591 | + "by": "SPB", | |
| 592 | + "note": "" | |
| 593 | + }, | |
| 594 | + { | |
| 595 | + "title": "SSH / network connectivity tested", | |
| 596 | + "status": "done", | |
| 597 | + "date": "", | |
| 598 | + "by": "SPB", | |
| 599 | + "note": "" | |
| 600 | + } | |
| 601 | + ] | |
| 602 | + } | |
| 603 | + ], | |
| 604 | + "saved_at": "2026-06-16 16:58" | |
| 605 | + } | |
| 606 | +} | |
| \ No newline at end of file | ||
added
nodes.json
+462 −0
@@ -0,0 +1,462 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "alias": "M3U96a", | |
| 4 | + "slug": "m3u96a", | |
| 5 | + "fqdn": "m3u96a.maclustr.io", | |
| 6 | + "model": "Mac Studio", | |
| 7 | + "modelid": "Mac15,14", | |
| 8 | + "chip": "Apple M3 Ultra", | |
| 9 | + "ncpu": 28, | |
| 10 | + "nperf": 20, | |
| 11 | + "neff": 8, | |
| 12 | + "gpu": 60, | |
| 13 | + "ne": 32, | |
| 14 | + "mem": 96, | |
| 15 | + "bw": "819 GB/s", | |
| 16 | + "internal": "1 TB", | |
| 17 | + "external": "1 TB", | |
| 18 | + "osv": "26.4", | |
| 19 | + "build": "25E5207k", | |
| 20 | + "serial": "HM62TG64LP", | |
| 21 | + "tier": "ULTRA", | |
| 22 | + "tcolor": "#5B21B6", | |
| 23 | + "role": "COMPUTE" | |
| 24 | + }, | |
| 25 | + { | |
| 26 | + "alias": "M3U96b", | |
| 27 | + "slug": "m3u96b", | |
| 28 | + "fqdn": "m3u96b.maclustr.io", | |
| 29 | + "model": "Mac Studio", | |
| 30 | + "modelid": "Mac15,14", | |
| 31 | + "chip": "Apple M3 Ultra", | |
| 32 | + "ncpu": 28, | |
| 33 | + "nperf": 20, | |
| 34 | + "neff": 8, | |
| 35 | + "gpu": 60, | |
| 36 | + "ne": 32, | |
| 37 | + "mem": 96, | |
| 38 | + "bw": "819 GB/s", | |
| 39 | + "internal": "1 TB", | |
| 40 | + "external": "1 TB", | |
| 41 | + "osv": "26.4", | |
| 42 | + "build": "25E5207k", | |
| 43 | + "serial": "J3661QHQ7M", | |
| 44 | + "tier": "ULTRA", | |
| 45 | + "tcolor": "#5B21B6", | |
| 46 | + "role": "COMPUTE" | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + "alias": "M2U64", | |
| 50 | + "slug": "m2u64", | |
| 51 | + "fqdn": "m2u64.maclustr.io", | |
| 52 | + "model": "Mac Studio", | |
| 53 | + "modelid": "Mac14,14", | |
| 54 | + "chip": "Apple M2 Ultra", | |
| 55 | + "ncpu": 24, | |
| 56 | + "nperf": 16, | |
| 57 | + "neff": 8, | |
| 58 | + "gpu": 60, | |
| 59 | + "ne": 32, | |
| 60 | + "mem": 64, | |
| 61 | + "bw": "800 GB/s", | |
| 62 | + "internal": "1 TB", | |
| 63 | + "external": "", | |
| 64 | + "osv": "26.5", | |
| 65 | + "build": "25F71", | |
| 66 | + "serial": "T99R24M36K", | |
| 67 | + "tier": "ULTRA", | |
| 68 | + "tcolor": "#5B21B6", | |
| 69 | + "role": "COMPUTE" | |
| 70 | + }, | |
| 71 | + { | |
| 72 | + "alias": "M4M64a", | |
| 73 | + "slug": "m4m64a", | |
| 74 | + "fqdn": "m4m64a.maclustr.io", | |
| 75 | + "model": "Mac Studio", | |
| 76 | + "modelid": "Mac16,9", | |
| 77 | + "chip": "Apple M4 Max", | |
| 78 | + "ncpu": 16, | |
| 79 | + "nperf": 12, | |
| 80 | + "neff": 4, | |
| 81 | + "gpu": 40, | |
| 82 | + "ne": 16, | |
| 83 | + "mem": 64, | |
| 84 | + "bw": "546 GB/s", | |
| 85 | + "internal": "1 TB", | |
| 86 | + "external": "1 TB", | |
| 87 | + "osv": "26.5.1", | |
| 88 | + "build": "25F80", | |
| 89 | + "serial": "YFQW7Y1734", | |
| 90 | + "tier": "MAX", | |
| 91 | + "tcolor": "#7C3AED", | |
| 92 | + "role": "COMPUTE" | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + "alias": "M4M64b", | |
| 96 | + "slug": "m4m64b", | |
| 97 | + "fqdn": "m4m64b.maclustr.io", | |
| 98 | + "model": "Mac Studio", | |
| 99 | + "modelid": "Mac16,9", | |
| 100 | + "chip": "Apple M4 Max", | |
| 101 | + "ncpu": 16, | |
| 102 | + "nperf": 12, | |
| 103 | + "neff": 4, | |
| 104 | + "gpu": 40, | |
| 105 | + "ne": 16, | |
| 106 | + "mem": 64, | |
| 107 | + "bw": "546 GB/s", | |
| 108 | + "internal": "1 TB", | |
| 109 | + "external": "2 TB", | |
| 110 | + "osv": "26.5.1", | |
| 111 | + "build": "25F80", | |
| 112 | + "serial": "F163L9H422", | |
| 113 | + "tier": "MAX", | |
| 114 | + "tcolor": "#7C3AED", | |
| 115 | + "role": "COMPUTE" | |
| 116 | + }, | |
| 117 | + { | |
| 118 | + "alias": "M4BP48", | |
| 119 | + "slug": "m4bp48", | |
| 120 | + "fqdn": "m4bp48.maclustr.io", | |
| 121 | + "model": "MacBook Pro", | |
| 122 | + "modelid": "Mac16,5", | |
| 123 | + "chip": "Apple M4 Max", | |
| 124 | + "ncpu": 16, | |
| 125 | + "nperf": 12, | |
| 126 | + "neff": 4, | |
| 127 | + "gpu": 40, | |
| 128 | + "ne": 16, | |
| 129 | + "mem": 48, | |
| 130 | + "bw": "546 GB/s", | |
| 131 | + "internal": "1 TB", | |
| 132 | + "external": "4 TB", | |
| 133 | + "osv": "26.5.1", | |
| 134 | + "build": "25F80", | |
| 135 | + "serial": "DRXR751720", | |
| 136 | + "tier": "MAX", | |
| 137 | + "tcolor": "#7C3AED", | |
| 138 | + "role": "COMPUTE" | |
| 139 | + }, | |
| 140 | + { | |
| 141 | + "alias": "M4BP36", | |
| 142 | + "slug": "m4bp36", | |
| 143 | + "fqdn": "m4bp36.maclustr.io", | |
| 144 | + "model": "MacBook Pro", | |
| 145 | + "modelid": "Mac16,6", | |
| 146 | + "chip": "Apple M4 Max", | |
| 147 | + "ncpu": 14, | |
| 148 | + "nperf": 10, | |
| 149 | + "neff": 4, | |
| 150 | + "gpu": 32, | |
| 151 | + "ne": 16, | |
| 152 | + "mem": 36, | |
| 153 | + "bw": "410 GB/s", | |
| 154 | + "internal": "1 TB", | |
| 155 | + "external": "", | |
| 156 | + "osv": "26.5.1", | |
| 157 | + "build": "25F80", | |
| 158 | + "serial": "DJ616D4NPF", | |
| 159 | + "tier": "MAX", | |
| 160 | + "tcolor": "#7C3AED", | |
| 161 | + "role": "COMPUTE" | |
| 162 | + }, | |
| 163 | + { | |
| 164 | + "alias": "M4M36", | |
| 165 | + "slug": "m4m36", | |
| 166 | + "fqdn": "m4m36.maclustr.io", | |
| 167 | + "model": "Mac Studio", | |
| 168 | + "modelid": "Mac16,9", | |
| 169 | + "chip": "Apple M4 Max", | |
| 170 | + "ncpu": 14, | |
| 171 | + "nperf": 10, | |
| 172 | + "neff": 4, | |
| 173 | + "gpu": 32, | |
| 174 | + "ne": 16, | |
| 175 | + "mem": 36, | |
| 176 | + "bw": "410 GB/s", | |
| 177 | + "internal": "512 GB", | |
| 178 | + "external": "2 TB", | |
| 179 | + "osv": "26.4.1", | |
| 180 | + "build": "25E253", | |
| 181 | + "serial": "FXGPY9V7NT", | |
| 182 | + "tier": "MAX", | |
| 183 | + "tcolor": "#7C3AED", | |
| 184 | + "role": "COMPUTE" | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "alias": "M2M32", | |
| 188 | + "slug": "m2m32", | |
| 189 | + "fqdn": "m2m32.maclustr.io", | |
| 190 | + "model": "Mac Studio", | |
| 191 | + "modelid": "Mac14,13", | |
| 192 | + "chip": "Apple M2 Max", | |
| 193 | + "ncpu": 12, | |
| 194 | + "nperf": 8, | |
| 195 | + "neff": 4, | |
| 196 | + "gpu": 30, | |
| 197 | + "ne": 16, | |
| 198 | + "mem": 32, | |
| 199 | + "bw": "400 GB/s", | |
| 200 | + "internal": "512 GB", | |
| 201 | + "external": "1 TB", | |
| 202 | + "osv": "26.5.1", | |
| 203 | + "build": "25F80", | |
| 204 | + "serial": "X5PF2TW170", | |
| 205 | + "tier": "MAX", | |
| 206 | + "tcolor": "#7C3AED", | |
| 207 | + "role": "COMPUTE" | |
| 208 | + }, | |
| 209 | + { | |
| 210 | + "alias": "M2M32b", | |
| 211 | + "slug": "m2m32b", | |
| 212 | + "fqdn": "m2m32b.maclustr.io", | |
| 213 | + "model": "Mac Studio", | |
| 214 | + "modelid": "Mac14,13", | |
| 215 | + "chip": "Apple M2 Max", | |
| 216 | + "ncpu": 12, | |
| 217 | + "nperf": 8, | |
| 218 | + "neff": 4, | |
| 219 | + "gpu": 30, | |
| 220 | + "ne": 16, | |
| 221 | + "mem": 32, | |
| 222 | + "bw": "400 GB/s", | |
| 223 | + "internal": "512 GB", | |
| 224 | + "external": "1 TB", | |
| 225 | + "osv": "26.5.1", | |
| 226 | + "build": "25F80", | |
| 227 | + "serial": "VHKX7M65XC", | |
| 228 | + "tier": "MAX", | |
| 229 | + "tcolor": "#7C3AED", | |
| 230 | + "role": "COMPUTE" | |
| 231 | + }, | |
| 232 | + { | |
| 233 | + "alias": "M2M32c", | |
| 234 | + "slug": "m2m32c", | |
| 235 | + "fqdn": "m2m32c.maclustr.io", | |
| 236 | + "model": "Mac Studio", | |
| 237 | + "modelid": "Mac14,13", | |
| 238 | + "chip": "Apple M2 Max", | |
| 239 | + "ncpu": 12, | |
| 240 | + "nperf": 8, | |
| 241 | + "neff": 4, | |
| 242 | + "gpu": 30, | |
| 243 | + "ne": 16, | |
| 244 | + "mem": 32, | |
| 245 | + "bw": "400 GB/s", | |
| 246 | + "internal": "512 GB", | |
| 247 | + "external": "4 TB", | |
| 248 | + "osv": "26.4.1", | |
| 249 | + "build": "25E253", | |
| 250 | + "serial": "W2QTCJ7TKG", | |
| 251 | + "tier": "MAX", | |
| 252 | + "tcolor": "#7C3AED", | |
| 253 | + "role": "COMPUTE" | |
| 254 | + }, | |
| 255 | + { | |
| 256 | + "alias": "m4mc", | |
| 257 | + "slug": "m4mc", | |
| 258 | + "fqdn": "m4mc.maclustr.io", | |
| 259 | + "model": "Mac mini", | |
| 260 | + "modelid": "Mac16,11", | |
| 261 | + "chip": "Apple M4 Pro", | |
| 262 | + "ncpu": 12, | |
| 263 | + "nperf": 8, | |
| 264 | + "neff": 4, | |
| 265 | + "gpu": 16, | |
| 266 | + "ne": 16, | |
| 267 | + "mem": 24, | |
| 268 | + "bw": "273 GB/s", | |
| 269 | + "internal": "512 GB", | |
| 270 | + "external": "2 TB", | |
| 271 | + "osv": "26.4.1", | |
| 272 | + "build": "25E253", | |
| 273 | + "serial": "J742M2X1VC", | |
| 274 | + "tier": "PRO", | |
| 275 | + "tcolor": "#3B82F6", | |
| 276 | + "role": "COMPUTE" | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + "alias": "M1M32", | |
| 280 | + "slug": "m1m32", | |
| 281 | + "fqdn": "m1m32.maclustr.io", | |
| 282 | + "model": "Mac Studio", | |
| 283 | + "modelid": "Mac13,1", | |
| 284 | + "chip": "Apple M1 Max", | |
| 285 | + "ncpu": 10, | |
| 286 | + "nperf": 8, | |
| 287 | + "neff": 2, | |
| 288 | + "gpu": 24, | |
| 289 | + "ne": 16, | |
| 290 | + "mem": 32, | |
| 291 | + "bw": "400 GB/s", | |
| 292 | + "internal": "512 GB", | |
| 293 | + "external": "2 TB", | |
| 294 | + "osv": "26.4.1", | |
| 295 | + "build": "25E253", | |
| 296 | + "serial": "LVC2Q4R716", | |
| 297 | + "tier": "MAX", | |
| 298 | + "tcolor": "#7C3AED", | |
| 299 | + "role": "COMPUTE" | |
| 300 | + }, | |
| 301 | + { | |
| 302 | + "alias": "m4ma", | |
| 303 | + "slug": "m4ma", | |
| 304 | + "fqdn": "m4ma.maclustr.io", | |
| 305 | + "model": "Mac mini", | |
| 306 | + "modelid": "Mac16,10", | |
| 307 | + "chip": "Apple M4", | |
| 308 | + "ncpu": 10, | |
| 309 | + "nperf": 4, | |
| 310 | + "neff": 6, | |
| 311 | + "gpu": 10, | |
| 312 | + "ne": 16, | |
| 313 | + "mem": 24, | |
| 314 | + "bw": "120 GB/s", | |
| 315 | + "internal": "512 GB", | |
| 316 | + "external": "2 TB", | |
| 317 | + "osv": "26.4.1", | |
| 318 | + "build": "25E253", | |
| 319 | + "serial": "N6NVTGFFVV", | |
| 320 | + "tier": "BASE", | |
| 321 | + "tcolor": "#06B6D4", | |
| 322 | + "role": "COMPUTE" | |
| 323 | + }, | |
| 324 | + { | |
| 325 | + "alias": "m4mb", | |
| 326 | + "slug": "m4mb", | |
| 327 | + "fqdn": "m4mb.maclustr.io", | |
| 328 | + "model": "Mac mini", | |
| 329 | + "modelid": "Mac16,10", | |
| 330 | + "chip": "Apple M4", | |
| 331 | + "ncpu": 10, | |
| 332 | + "nperf": 4, | |
| 333 | + "neff": 6, | |
| 334 | + "gpu": 10, | |
| 335 | + "ne": 16, | |
| 336 | + "mem": 16, | |
| 337 | + "bw": "120 GB/s", | |
| 338 | + "internal": "256 GB", | |
| 339 | + "external": "2 TB", | |
| 340 | + "osv": "26.4.1", | |
| 341 | + "build": "25E253", | |
| 342 | + "serial": "GYWPHYG6TX", | |
| 343 | + "tier": "BASE", | |
| 344 | + "tcolor": "#06B6D4", | |
| 345 | + "role": "COMPUTE" | |
| 346 | + }, | |
| 347 | + { | |
| 348 | + "alias": "M3BA24", | |
| 349 | + "slug": "m3ba24", | |
| 350 | + "fqdn": "m3ba24.maclustr.io", | |
| 351 | + "model": "MacBook Air", | |
| 352 | + "modelid": "Mac15,12", | |
| 353 | + "chip": "Apple M3", | |
| 354 | + "ncpu": 8, | |
| 355 | + "nperf": 4, | |
| 356 | + "neff": 4, | |
| 357 | + "gpu": 10, | |
| 358 | + "ne": 16, | |
| 359 | + "mem": 24, | |
| 360 | + "bw": "100 GB/s", | |
| 361 | + "internal": "512 GB", | |
| 362 | + "external": "", | |
| 363 | + "osv": "26.5.1", | |
| 364 | + "build": "25F80", | |
| 365 | + "serial": "M4KXY1V9CL", | |
| 366 | + "tier": "BASE", | |
| 367 | + "tcolor": "#06B6D4", | |
| 368 | + "role": "COMPUTE" | |
| 369 | + }, | |
| 370 | + { | |
| 371 | + "alias": "m2m16", | |
| 372 | + "slug": "m2m16", | |
| 373 | + "fqdn": "m2m16.maclustr.io", | |
| 374 | + "model": "Mac mini", | |
| 375 | + "modelid": "Mac14,12", | |
| 376 | + "chip": "Apple M2 Pro", | |
| 377 | + "ncpu": 10, | |
| 378 | + "nperf": 6, | |
| 379 | + "neff": 4, | |
| 380 | + "gpu": 16, | |
| 381 | + "ne": 16, | |
| 382 | + "mem": 16, | |
| 383 | + "bw": "200 GB/s", | |
| 384 | + "internal": "512 GB", | |
| 385 | + "external": "", | |
| 386 | + "osv": "26.4.1", | |
| 387 | + "build": "25E253", | |
| 388 | + "serial": "LFWH0GVGC6", | |
| 389 | + "tier": "PRO", | |
| 390 | + "tcolor": "#3B82F6", | |
| 391 | + "role": "COMPUTE" | |
| 392 | + }, | |
| 393 | + { | |
| 394 | + "alias": "M3BA16", | |
| 395 | + "slug": "m3ba16", | |
| 396 | + "fqdn": "m3ba16.maclustr.io", | |
| 397 | + "model": "MacBook Air", | |
| 398 | + "modelid": "Mac15,12", | |
| 399 | + "chip": "Apple M3", | |
| 400 | + "ncpu": 8, | |
| 401 | + "nperf": 4, | |
| 402 | + "neff": 4, | |
| 403 | + "gpu": 10, | |
| 404 | + "ne": 16, | |
| 405 | + "mem": 16, | |
| 406 | + "bw": "100 GB/s", | |
| 407 | + "internal": "512 GB", | |
| 408 | + "external": "", | |
| 409 | + "osv": "26.5.1", | |
| 410 | + "build": "25F80", | |
| 411 | + "serial": "LTQX17FW9W", | |
| 412 | + "tier": "BASE", | |
| 413 | + "tcolor": "#06B6D4", | |
| 414 | + "role": "COMPUTE" | |
| 415 | + }, | |
| 416 | + { | |
| 417 | + "alias": "m2m8a", | |
| 418 | + "slug": "m2m8a", | |
| 419 | + "fqdn": "m2m8a.maclustr.io", | |
| 420 | + "model": "Mac mini", | |
| 421 | + "modelid": "Mac14,3", | |
| 422 | + "chip": "Apple M2", | |
| 423 | + "ncpu": 8, | |
| 424 | + "nperf": 4, | |
| 425 | + "neff": 4, | |
| 426 | + "gpu": 10, | |
| 427 | + "ne": 16, | |
| 428 | + "mem": 8, | |
| 429 | + "bw": "100 GB/s", | |
| 430 | + "internal": "512 GB", | |
| 431 | + "external": "", | |
| 432 | + "osv": "26.5.1", | |
| 433 | + "build": "25F80", | |
| 434 | + "serial": "WQF2N4L6RT", | |
| 435 | + "tier": "BASE", | |
| 436 | + "tcolor": "#06B6D4", | |
| 437 | + "role": "COMPUTE" | |
| 438 | + }, | |
| 439 | + { | |
| 440 | + "alias": "m2m8b", | |
| 441 | + "slug": "m2m8b", | |
| 442 | + "fqdn": "m2m8b.maclustr.io", | |
| 443 | + "model": "Mac mini", | |
| 444 | + "modelid": "Mac14,3", | |
| 445 | + "chip": "Apple M2", | |
| 446 | + "ncpu": 8, | |
| 447 | + "nperf": 4, | |
| 448 | + "neff": 4, | |
| 449 | + "gpu": 10, | |
| 450 | + "ne": 16, | |
| 451 | + "mem": 8, | |
| 452 | + "bw": "100 GB/s", | |
| 453 | + "internal": "256 GB", | |
| 454 | + "external": "", | |
| 455 | + "osv": "26.5.1", | |
| 456 | + "build": "25F80", | |
| 457 | + "serial": "Q339C7TMT7", | |
| 458 | + "tier": "BASE", | |
| 459 | + "tcolor": "#06B6D4", | |
| 460 | + "role": "COMPUTE" | |
| 461 | + } | |
| 462 | +] | |
| \ No newline at end of file | ||
added
server.py
+555 −0
@@ -0,0 +1,555 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""MacLustr node pages — live metrics + maintenance schedule. | |
| 3 | +Stdlib only (Python 3.9+). Runs on m2u64, serves http://0.0.0.0:8003. | |
| 4 | +Each node label QR -> https://node.maclustr.io/<slug>.""" | |
| 5 | +import json, os, subprocess, threading, time, html | |
| 6 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| 7 | +from urllib.parse import urlparse | |
| 8 | + | |
| 9 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
| 10 | +PORT = 8003 | |
| 11 | +SELF_SLUG = "m2u64" # this server runs ON m2u64 -> collect locally | |
| 12 | +KEY = os.path.expanduser("~/.ssh/id_ed25519") | |
| 13 | +MAINT_FILE = os.path.join(HERE, "maintenance.json") | |
| 14 | + | |
| 15 | +NODES = json.load(open(os.path.join(HERE, "nodes.json"))) | |
| 16 | +BY_SLUG = {n["slug"]: n for n in NODES} | |
| 17 | + | |
| 18 | +# recurring maintenance schedule (checklist) — (title, frequency, detail) | |
| 19 | +TASKS = [ | |
| 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 | +] | |
| 41 | + | |
| 42 | +# dropdown vocabularies for the maintenance form | |
| 43 | +STATUS_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 | +] | |
| 52 | +STATUS_COLOR = { | |
| 53 | + "done": "#16a34a", "progress": "#2563eb", "scheduled": "#7c3aed", | |
| 54 | + "skip": "#9ca3af", "attention": "#d97706", "failed": "#dc2626", "": "transparent", | |
| 55 | +} | |
| 56 | +CONDITION_OPTS = [ | |
| 57 | + ("", "— Overall condition —"), | |
| 58 | + ("excellent", "🟢 Excellent"), | |
| 59 | + ("good", "🟢 Good"), | |
| 60 | + ("fair", "🟡 Fair"), | |
| 61 | + ("poor", "🟠 Poor — service soon"), | |
| 62 | + ("critical", "🔴 Needs service now"), | |
| 63 | +] | |
| 64 | +BY_SUGGEST = ["SPB", "Remote", "On-site"] # datalist suggestions for "performed by" | |
| 65 | +STATUS_LABEL = {v: lbl for v, lbl in STATUS_OPTS} | |
| 66 | +CONDITION_LABEL = {v: lbl for v, lbl in CONDITION_OPTS} | |
| 67 | + | |
| 68 | +# ---- live metrics collector (runs on the target node) ---- | |
| 69 | +COLLECT = r''' | |
| 70 | +PAGESIZE=$(sysctl -n hw.pagesize 2>/dev/null || echo 16384) | |
| 71 | +CPULINE=$(top -l 2 -n 0 2>/dev/null | grep "CPU usage" | tail -1) | |
| 72 | +USR=$(echo "$CPULINE" | sed -E 's/.*: ([0-9.]+)% user.*/\1/') | |
| 73 | +SYS=$(echo "$CPULINE" | sed -E 's/.*user, ([0-9.]+)% sys.*/\1/') | |
| 74 | +IDLE=$(echo "$CPULINE" | sed -E 's/.*[, ]([0-9.]+)% idle.*/\1/') | |
| 75 | +NCPU=$(sysctl -n hw.ncpu 2>/dev/null) | |
| 76 | +LOAD=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}') | |
| 77 | +MEMTOTAL=$(sysctl -n hw.memsize 2>/dev/null) | |
| 78 | +VM=$(vm_stat 2>/dev/null) | |
| 79 | +g(){ echo "$VM" | awk -F: "/$1/{gsub(/[^0-9]/,\"\",\$2); print \$2}"; } | |
| 80 | +ACTIVE=$(g "Pages active"); WIRED=$(g "Pages wired down"); COMP=$(g "Pages occupied by compressor") | |
| 81 | +FREE=$(g "Pages free"); SPEC=$(g "Pages speculative") | |
| 82 | +DF=$(df -k / 2>/dev/null | awk 'NR==2{print $2","$3","$4","$5}') | |
| 83 | +SWAPT=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*total = ([0-9.]+)M.*/\1/') | |
| 84 | +SWAPU=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*used = ([0-9.]+)M.*/\1/') | |
| 85 | +UP=$(uptime | sed -E 's/^.*up *//; s/, *[0-9]+ users?.*//; s/, *load.*//') | |
| 86 | +BOOT=$(sysctl -n kern.boottime 2>/dev/null | sed -E 's/.*sec = ([0-9]+).*/\1/') | |
| 87 | +NPROC=$(ps -A 2>/dev/null | wc -l | tr -d ' ') | |
| 88 | +TOP=$(ps -Aceo pcpu,comm -r 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') | |
| 89 | +TOPMEM=$(ps -Aceo rss,comm -m 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') | |
| 90 | +IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) | |
| 91 | +THERM=$(pmset -g therm 2>/dev/null | sed -nE 's/.*CPU_Speed_Limit = ([0-9]+).*/\1/p') | |
| 92 | +BATT=$(pmset -g batt 2>/dev/null | grep -oE '[0-9]+%' | head -1) | |
| 93 | +OSV=$(sw_vers -productVersion 2>/dev/null) | |
| 94 | +BUILD=$(sw_vers -buildVersion 2>/dev/null) | |
| 95 | +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" \ | |
| 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 | +''' | |
| 98 | + | |
| 99 | +_cache = {} | |
| 100 | +_cache_lock = threading.Lock() | |
| 101 | +TTL = 4 | |
| 102 | + | |
| 103 | +def _run(slug, node): | |
| 104 | + if slug == SELF_SLUG: | |
| 105 | + cmd = ["bash", "-c", COLLECT]; inp = None | |
| 106 | + 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 = COLLECT | |
| 110 | + 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] = v | |
| 118 | + 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]} | |
| 123 | + | |
| 124 | +def _f(x, d=0.0): | |
| 125 | + try: return float(x) | |
| 126 | + except: return d | |
| 127 | + | |
| 128 | +def _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 out | |
| 134 | + | |
| 135 | +def _parse(kv): | |
| 136 | + pg = _f(kv.get("PAGESIZE"), 16384) | |
| 137 | + memtot = _f(kv.get("MEMTOTAL")) | |
| 138 | + app = _f(kv.get("ACTIVE")) * pg | |
| 139 | + wired = _f(kv.get("WIRED")) * pg | |
| 140 | + comp = _f(kv.get("COMP")) * pg | |
| 141 | + used = app + wired + comp | |
| 142 | + 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 0 | |
| 145 | + dused = _f(df[1]) * 1024 if len(df) > 1 else 0 | |
| 146 | + dfree = _f(df[2]) * 1024 if len(df) > 2 else 0 | |
| 147 | + 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 | + } | |
| 175 | + | |
| 176 | +def 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 data | |
| 186 | + | |
| 187 | +# ---- maintenance persistence ---- | |
| 188 | +_maint_lock = threading.Lock() | |
| 189 | +def load_maint(): | |
| 190 | + try: return json.load(open(MAINT_FILE)) | |
| 191 | + except: return {} | |
| 192 | +def save_maint(d): | |
| 193 | + with _maint_lock: | |
| 194 | + json.dump(d, open(MAINT_FILE, "w"), indent=2) | |
| 195 | +def get_maint(slug): | |
| 196 | + return load_maint().get(slug, {"tasks": {}, "next": "", "log": "", | |
| 197 | + "condition": "", "inspected_by": "", "reports": []}) | |
| 198 | + | |
| 199 | +def _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} | |
| 214 | + | |
| 215 | +def 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 first | |
| 221 | + reports = reports[:60] # keep last 60 reports | |
| 222 | + 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] | |
| 229 | + | |
| 230 | +def esc(s): return html.escape(str(s)) | |
| 231 | + | |
| 232 | +CSS = """ | |
| 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} | |
| 236 | +html{-webkit-text-size-adjust:100%} | |
| 237 | +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} | |
| 238 | +a{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); | |
| 245 | +display: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)} | |
| 254 | +h2{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); | |
| 276 | +background:#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}} | |
| 283 | +input[type=checkbox]{width:22px;height:22px;accent-color:var(--green);cursor:pointer} | |
| 284 | +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} | |
| 285 | +input:focus,textarea:focus,select:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,58,237,.12)} | |
| 286 | +textarea{min-height:80px;resize:vertical} | |
| 287 | +select{-webkit-appearance:none;appearance:none;cursor:pointer;padding-right:30px; | |
| 288 | +background-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>"); | |
| 289 | +background-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 | +""" | |
| 311 | + | |
| 312 | +def 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"> | |
| 332 | + | |
| 333 | +<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> | |
| 336 | + | |
| 337 | +<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> | |
| 345 | + | |
| 346 | +<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> | |
| 364 | + | |
| 365 | +<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> | |
| 369 | + | |
| 370 | +<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> | |
| 379 | + | |
| 380 | +<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> | |
| 388 | + | |
| 389 | +<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> | |
| 404 | + | |
| 405 | +<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> | |
| 408 | + | |
| 409 | +<div class="foot">MacLustr node page · auto-refreshes every 5s · <a href="/">cluster overview</a></div> | |
| 410 | +</div> | |
| 411 | +<script> | |
| 412 | +const SLUG="{slug}"; | |
| 413 | +const $=id=>document.getElementById(id); | |
| 414 | +function 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;}} | |
| 417 | +function 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>';}} | |
| 419 | +async 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 | +}} | |
| 435 | +const SCOLOR={json.dumps(STATUS_COLOR)}; | |
| 436 | +const SLABEL={json.dumps(STATUS_LABEL)}; | |
| 437 | +const CLABEL={json.dumps(CONDITION_LABEL)}; | |
| 438 | +function esc(s){{return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({{'&':'&','<':'<','>':'>','"':'"'}}[c]));}} | |
| 439 | +function colorRow(sel){{const r=sel.closest('.mrow');if(r)r.style.borderLeftColor=SCOLOR[sel.value]||'transparent';}} | |
| 440 | +function 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>`;}} | |
| 442 | +function 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('');}} | |
| 458 | +async 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;}} | |
| 468 | +async 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;}} | |
| 479 | +refresh();loadM();setInterval(refresh,5000); | |
| 480 | +</script></body></html>""" | |
| 481 | + | |
| 482 | +def 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> | |
| 504 | +const S={json.dumps([n["slug"] for n in NODES])},$=id=>document.getElementById(id); | |
| 505 | +async 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){{}}}} | |
| 509 | +function all(){{S.forEach((s,i)=>setTimeout(()=>poll(s),i*100));}} | |
| 510 | +all();setInterval(all,8000); | |
| 511 | +</script></body></html>""" | |
| 512 | + | |
| 513 | +LOGO_PATH = os.path.join(HERE, "logo.svg") | |
| 514 | +LOGO = open(LOGO_PATH).read() if os.path.exists(LOGO_PATH) else "<svg/>" | |
| 515 | + | |
| 516 | +class H(BaseHTTPRequestHandler): | |
| 517 | + def log_message(self, *a): pass | |
| 518 | + def _send(self, code, body, ctype="text/html; charset=utf-8"): | |
| 519 | + b = body.encode() if isinstance(body, str) else body | |
| 520 | + 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): pass | |
| 527 | + 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") | |
| 551 | + | |
| 552 | +if __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() | |
added
server.py.bak.20260616-134956
+426 −0
@@ -0,0 +1,426 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""MacLustr node pages — live metrics + maintenance schedule. | |
| 3 | +Stdlib only (Python 3.9+). Runs on m2u64, serves http://0.0.0.0:8003. | |
| 4 | +Each node label QR -> https://node.maclustr.io/<slug>.""" | |
| 5 | +import json, os, subprocess, threading, time, html | |
| 6 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| 7 | +from urllib.parse import urlparse | |
| 8 | + | |
| 9 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
| 10 | +PORT = 8003 | |
| 11 | +SELF_SLUG = "m2u64" # this server runs ON m2u64 -> collect locally | |
| 12 | +KEY = os.path.expanduser("~/.ssh/id_ed25519") | |
| 13 | +MAINT_FILE = os.path.join(HERE, "maintenance.json") | |
| 14 | + | |
| 15 | +NODES = json.load(open(os.path.join(HERE, "nodes.json"))) | |
| 16 | +BY_SLUG = {n["slug"]: n for n in NODES} | |
| 17 | + | |
| 18 | +# recurring maintenance schedule (checklist) | |
| 19 | +TASKS = [ | |
| 20 | + ("Dust / ventilation cleaned", "Quarterly"), | |
| 21 | + ("macOS updated", "Monthly"), | |
| 22 | + ("Disk space checked (>15% free)", "Monthly"), | |
| 23 | + ("Scheduled reboot performed", "Monthly"), | |
| 24 | + ("SSH / network connectivity tested", "Monthly"), | |
| 25 | + ("Backup / Time Machine verified", "Weekly"), | |
| 26 | + ("Temperatures / throttling checked", "Quarterly"), | |
| 27 | + ("Cabling & power inspected", "Quarterly"), | |
| 28 | + ("System logs reviewed (Console)", "Monthly"), | |
| 29 | + ("External SSD health checked", "Quarterly"), | |
| 30 | +] | |
| 31 | + | |
| 32 | +# ---- live metrics collector (runs on the target node) ---- | |
| 33 | +COLLECT = r''' | |
| 34 | +PAGESIZE=$(sysctl -n hw.pagesize 2>/dev/null || echo 16384) | |
| 35 | +CPULINE=$(top -l 2 -n 0 2>/dev/null | grep "CPU usage" | tail -1) | |
| 36 | +USR=$(echo "$CPULINE" | sed -E 's/.*: ([0-9.]+)% user.*/\1/') | |
| 37 | +SYS=$(echo "$CPULINE" | sed -E 's/.*user, ([0-9.]+)% sys.*/\1/') | |
| 38 | +IDLE=$(echo "$CPULINE" | sed -E 's/.*[, ]([0-9.]+)% idle.*/\1/') | |
| 39 | +NCPU=$(sysctl -n hw.ncpu 2>/dev/null) | |
| 40 | +LOAD=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}') | |
| 41 | +MEMTOTAL=$(sysctl -n hw.memsize 2>/dev/null) | |
| 42 | +VM=$(vm_stat 2>/dev/null) | |
| 43 | +g(){ echo "$VM" | awk -F: "/$1/{gsub(/[^0-9]/,\"\",\$2); print \$2}"; } | |
| 44 | +ACTIVE=$(g "Pages active"); WIRED=$(g "Pages wired down"); COMP=$(g "Pages occupied by compressor") | |
| 45 | +FREE=$(g "Pages free"); SPEC=$(g "Pages speculative") | |
| 46 | +DF=$(df -k / 2>/dev/null | awk 'NR==2{print $2","$3","$4","$5}') | |
| 47 | +SWAPT=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*total = ([0-9.]+)M.*/\1/') | |
| 48 | +SWAPU=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*used = ([0-9.]+)M.*/\1/') | |
| 49 | +UP=$(uptime | sed -E 's/^.*up *//; s/, *[0-9]+ users?.*//; s/, *load.*//') | |
| 50 | +BOOT=$(sysctl -n kern.boottime 2>/dev/null | sed -E 's/.*sec = ([0-9]+).*/\1/') | |
| 51 | +NPROC=$(ps -A 2>/dev/null | wc -l | tr -d ' ') | |
| 52 | +TOP=$(ps -Aceo pcpu,comm -r 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') | |
| 53 | +TOPMEM=$(ps -Aceo rss,comm -m 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') | |
| 54 | +IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) | |
| 55 | +THERM=$(pmset -g therm 2>/dev/null | sed -nE 's/.*CPU_Speed_Limit = ([0-9]+).*/\1/p') | |
| 56 | +BATT=$(pmset -g batt 2>/dev/null | grep -oE '[0-9]+%' | head -1) | |
| 57 | +OSV=$(sw_vers -productVersion 2>/dev/null) | |
| 58 | +BUILD=$(sw_vers -buildVersion 2>/dev/null) | |
| 59 | +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" \ | |
| 60 | + "$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" | |
| 61 | +''' | |
| 62 | + | |
| 63 | +_cache = {} | |
| 64 | +_cache_lock = threading.Lock() | |
| 65 | +TTL = 4 | |
| 66 | + | |
| 67 | +def _run(slug, node): | |
| 68 | + if slug == SELF_SLUG: | |
| 69 | + cmd = ["bash", "-c", COLLECT]; inp = None | |
| 70 | + else: | |
| 71 | + cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", | |
| 72 | + "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=8", | |
| 73 | + "-i", KEY, node["fqdn"], "bash -s"]; inp = COLLECT | |
| 74 | + try: | |
| 75 | + p = subprocess.run(cmd, input=inp, capture_output=True, text=True, timeout=20) | |
| 76 | + if p.returncode != 0: | |
| 77 | + return {"online": False, "error": (p.stderr or "ssh failed").strip()[:200]} | |
| 78 | + kv = {} | |
| 79 | + for line in p.stdout.splitlines(): | |
| 80 | + if "=" in line: | |
| 81 | + k, v = line.split("=", 1); kv[k] = v | |
| 82 | + return _parse(kv) | |
| 83 | + except subprocess.TimeoutExpired: | |
| 84 | + return {"online": False, "error": "timeout"} | |
| 85 | + except Exception as e: | |
| 86 | + return {"online": False, "error": str(e)[:200]} | |
| 87 | + | |
| 88 | +def _f(x, d=0.0): | |
| 89 | + try: return float(x) | |
| 90 | + except: return d | |
| 91 | + | |
| 92 | +def _procs(s): | |
| 93 | + out = [] | |
| 94 | + for chunk in (s or "").split(";"): | |
| 95 | + if "|" in chunk: | |
| 96 | + a, b = chunk.split("|", 1); out.append((a, b)) | |
| 97 | + return out | |
| 98 | + | |
| 99 | +def _parse(kv): | |
| 100 | + pg = _f(kv.get("PAGESIZE"), 16384) | |
| 101 | + memtot = _f(kv.get("MEMTOTAL")) | |
| 102 | + app = _f(kv.get("ACTIVE")) * pg | |
| 103 | + wired = _f(kv.get("WIRED")) * pg | |
| 104 | + comp = _f(kv.get("COMP")) * pg | |
| 105 | + used = app + wired + comp | |
| 106 | + idle = _f(kv.get("IDLE")) | |
| 107 | + df = (kv.get("DF") or "0,0,0,0%").split(",") | |
| 108 | + dtot = _f(df[0]) * 1024 if len(df) > 0 else 0 | |
| 109 | + dused = _f(df[1]) * 1024 if len(df) > 1 else 0 | |
| 110 | + dfree = _f(df[2]) * 1024 if len(df) > 2 else 0 | |
| 111 | + dpct = _f("".join(ch for ch in (df[3] if len(df) > 3 else "0") if ch.isdigit() or ch == ".")) | |
| 112 | + load = (kv.get("LOAD") or "").split() | |
| 113 | + g = lambda b: round(b / 1e9, 1) | |
| 114 | + topcpu = [{"v": a, "name": b} for a, b in _procs(kv.get("TOP"))][:5] | |
| 115 | + topmem = [{"v": str(round(_f(a) / 1024)), "name": b} for a, b in _procs(kv.get("TOPMEM"))][:5] | |
| 116 | + therm = kv.get("THERM", "") | |
| 117 | + return { | |
| 118 | + "online": True, | |
| 119 | + "cpu_pct": round(max(0.0, min(100.0, 100.0 - idle)), 1), | |
| 120 | + "cpu_user": round(_f(kv.get("USR")), 1), | |
| 121 | + "cpu_sys": round(_f(kv.get("SYS")), 1), | |
| 122 | + "ncpu": kv.get("NCPU", "?"), | |
| 123 | + "mem_pct": round(used / memtot * 100, 1) if memtot else 0, | |
| 124 | + "mem_used_gb": g(used), "mem_total_gb": g(memtot), | |
| 125 | + "mem_app_gb": g(app), "mem_wired_gb": g(wired), "mem_comp_gb": g(comp), | |
| 126 | + "disk_pct": round(dpct, 1), | |
| 127 | + "disk_used_gb": g(dused), "disk_free_gb": g(dfree), "disk_total_gb": g(dtot), | |
| 128 | + "load": load[:3], | |
| 129 | + "swap_used": kv.get("SWAPU", "0"), "swap_total": kv.get("SWAPT", "0"), | |
| 130 | + "uptime": (kv.get("UPTIME") or "?").strip(), | |
| 131 | + "nproc": kv.get("NPROC", "?"), | |
| 132 | + "osv": kv.get("OSV", "?"), "build": kv.get("BUILD", "?"), | |
| 133 | + "ip": kv.get("IP", ""), | |
| 134 | + "batt": kv.get("BATT", ""), | |
| 135 | + "thermal": "Throttled (" + therm + "%)" if (therm and therm != "100") else ("Nominal" if therm else ""), | |
| 136 | + "topcpu": topcpu, "topmem": topmem, | |
| 137 | + "ts": int(time.time()), | |
| 138 | + } | |
| 139 | + | |
| 140 | +def live(slug): | |
| 141 | + node = BY_SLUG[slug]; now = time.time() | |
| 142 | + with _cache_lock: | |
| 143 | + c = _cache.get(slug) | |
| 144 | + if c and now - c[0] < TTL: | |
| 145 | + return c[1] | |
| 146 | + data = _run(slug, node) | |
| 147 | + with _cache_lock: | |
| 148 | + _cache[slug] = (now, data) | |
| 149 | + return data | |
| 150 | + | |
| 151 | +# ---- maintenance persistence ---- | |
| 152 | +_maint_lock = threading.Lock() | |
| 153 | +def load_maint(): | |
| 154 | + try: return json.load(open(MAINT_FILE)) | |
| 155 | + except: return {} | |
| 156 | +def save_maint(d): | |
| 157 | + with _maint_lock: | |
| 158 | + json.dump(d, open(MAINT_FILE, "w"), indent=2) | |
| 159 | +def get_maint(slug): | |
| 160 | + return load_maint().get(slug, {"tasks": {}, "next": "", "log": ""}) | |
| 161 | +def set_maint(slug, payload): | |
| 162 | + d = load_maint() | |
| 163 | + d[slug] = {"tasks": payload.get("tasks", {}), "next": payload.get("next", ""), | |
| 164 | + "log": payload.get("log", ""), "saved_at": time.strftime("%Y-%m-%d %H:%M")} | |
| 165 | + save_maint(d); return d[slug] | |
| 166 | + | |
| 167 | +def esc(s): return html.escape(str(s)) | |
| 168 | + | |
| 169 | +CSS = """ | |
| 170 | +:root{--bg:#f2f3f7;--card:#fff;--ink:#1d1d1f;--mut:#6b7280;--line:#e6e8ef;--acc:#7c3aed; | |
| 171 | +--green:#16a34a;--amber:#d97706;--red:#dc2626;--shadow:0 1px 3px rgba(16,24,40,.06),0 1px 2px rgba(16,24,40,.04)} | |
| 172 | +*{box-sizing:border-box;margin:0;padding:0} | |
| 173 | +html{-webkit-text-size-adjust:100%} | |
| 174 | +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} | |
| 175 | +a{color:var(--acc);text-decoration:none} | |
| 176 | +.wrap{max-width:1080px;margin:0 auto;padding:18px 16px 64px} | |
| 177 | +.bar-top{display:flex;align-items:center;gap:12px;margin-bottom:18px} | |
| 178 | +.bar-top .logo{width:42px;height:42px;border-radius:11px;box-shadow:var(--shadow)} | |
| 179 | +.bar-top .brand{font-weight:800;letter-spacing:.4px;font-size:13px;color:var(--mut)} | |
| 180 | +.bar-top .back{margin-left:auto;font-size:13px;font-weight:600;color:var(--mut)} | |
| 181 | +.hero{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:var(--shadow); | |
| 182 | +display:flex;align-items:center;gap:16px;flex-wrap:wrap} | |
| 183 | +.hero h1{font-size:clamp(34px,9vw,58px);font-weight:850;letter-spacing:-1.5px;line-height:1} | |
| 184 | +.hero .meta{color:var(--mut);font-size:14px;margin-top:6px} | |
| 185 | +.badge{padding:7px 16px;border-radius:10px;color:#fff;font-weight:800;font-size:16px;margin-left:auto} | |
| 186 | +.chiptag{display:inline-block;background:#111;color:#fff;font-weight:800;font-size:13px;padding:4px 12px;border-radius:8px;margin-top:8px} | |
| 187 | +.status{display:flex;align-items:center;gap:8px;margin:26px 2px 12px;font-size:13px;color:var(--mut)} | |
| 188 | +.dot{width:10px;height:10px;border-radius:50%;background:#9ca3af;display:inline-block} | |
| 189 | +.dot.on{background:var(--green);box-shadow:0 0 0 4px rgba(22,163,74,.15)} | |
| 190 | +.dot.off{background:var(--red);box-shadow:0 0 0 4px rgba(220,38,38,.15)} | |
| 191 | +h2{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--mut);margin:30px 4px 12px} | |
| 192 | +.grid{display:grid;gap:12px} | |
| 193 | +.g-metric{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))} | |
| 194 | +.g-spec{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))} | |
| 195 | +.g-two{grid-template-columns:1fr 1fr} | |
| 196 | +@media(max-width:560px){.g-two{grid-template-columns:1fr}} | |
| 197 | +.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 16px;box-shadow:var(--shadow)} | |
| 198 | +.k{font-size:11px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)} | |
| 199 | +.v{font-size:30px;font-weight:850;margin-top:4px;font-variant-numeric:tabular-nums} | |
| 200 | +.v small{font-size:14px;color:var(--mut);font-weight:700} | |
| 201 | +.sub{color:var(--mut);font-size:12.5px;margin-top:3px} | |
| 202 | +.bar{height:8px;border-radius:5px;background:#eceef4;overflow:hidden;margin-top:10px} | |
| 203 | +.bar>i{display:block;height:100%;border-radius:5px;width:0;transition:width .6s ease} | |
| 204 | +.row{display:flex;justify-content:space-between;font-size:13.5px;padding:7px 0;border-bottom:1px solid var(--line)} | |
| 205 | +.row:last-child{border-bottom:0} | |
| 206 | +.row b{font-weight:700} | |
| 207 | +.proc{display:flex;justify-content:space-between;font-size:13px;padding:5px 0;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums} | |
| 208 | +.proc:last-child{border-bottom:0} | |
| 209 | +.proc .nm{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:72%} | |
| 210 | +.proc .pc{color:var(--mut);font-weight:700} | |
| 211 | +.mwrap{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden} | |
| 212 | +.mrow{display:grid;grid-template-columns:30px 1fr 150px 1fr;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)} | |
| 213 | +.mrow:last-child{border-bottom:0} | |
| 214 | +.mrow .t{font-weight:600;font-size:14.5px} | |
| 215 | +.mrow .freq{font-size:11px;color:var(--mut);font-weight:700;text-transform:uppercase;letter-spacing:.4px} | |
| 216 | +@media(max-width:620px){.mrow{grid-template-columns:30px 1fr;row-gap:8px}.mrow .md,.mrow .mn{grid-column:2}} | |
| 217 | +input[type=checkbox]{width:22px;height:22px;accent-color:var(--green);cursor:pointer} | |
| 218 | +input[type=date],input[type=text],textarea{background:#fbfbfd;border:1px solid #d8dbe6;color:var(--ink);border-radius:9px;padding:9px 10px;font-size:14px;width:100%;font-family:inherit} | |
| 219 | +input:focus,textarea:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,58,237,.12)} | |
| 220 | +textarea{min-height:80px;resize:vertical} | |
| 221 | +.savebar{display:flex;align-items:center;gap:14px;margin-top:16px;flex-wrap:wrap} | |
| 222 | +.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)} | |
| 223 | +.btn:active{transform:translateY(1px)} | |
| 224 | +.saved{color:var(--green);font-weight:700;opacity:0;transition:opacity .3s} | |
| 225 | +.muted{color:var(--mut);font-size:13px} | |
| 226 | +.foot{margin-top:34px;color:var(--mut);font-size:12px;text-align:center} | |
| 227 | +""" | |
| 228 | + | |
| 229 | +def node_page(n): | |
| 230 | + slug = n["slug"]; tc = n["tcolor"] | |
| 231 | + storage = f'{n["internal"]} SSD' + (f' + {n["external"]} external' if n["external"] else "") | |
| 232 | + rows = "".join( | |
| 233 | + f'<div class="mrow"><input type="checkbox" data-i="{i}" class="mck">' | |
| 234 | + f'<div><div class="t">{esc(t)}</div><div class="freq">{esc(fr)}</div></div>' | |
| 235 | + f'<input type="date" data-i="{i}" class="md mdate">' | |
| 236 | + f'<input type="text" data-i="{i}" class="mn mnote" placeholder="note / signed by">' | |
| 237 | + f'</div>' | |
| 238 | + for i, (t, fr) in enumerate(TASKS)) | |
| 239 | + return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| 240 | +<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| 241 | +<meta name="theme-color" content="#f2f3f7"> | |
| 242 | +<title>{esc(n['alias'])} · MacLustr</title><style>{CSS}</style></head><body><div class="wrap"> | |
| 243 | + | |
| 244 | +<div class="bar-top"><img class="logo" src="/logo.svg" alt=""> | |
| 245 | +<span class="brand">MACLUSTR · COMPUTE CLUSTER</span> | |
| 246 | +<a class="back" href="/">All nodes →</a></div> | |
| 247 | + | |
| 248 | +<div class="hero"> | |
| 249 | + <div> | |
| 250 | + <h1>{esc(n['alias'])}</h1> | |
| 251 | + <div class="meta">{esc(n['fqdn'])} · {esc(n['model'])} ({esc(n['modelid'])})</div> | |
| 252 | + <span class="chiptag">{esc(n['chip'])}</span> | |
| 253 | + </div> | |
| 254 | + <span class="badge" style="background:{tc}">{esc(n['tier'])}</span> | |
| 255 | +</div> | |
| 256 | + | |
| 257 | +<div class="status"><span id="dot" class="dot"></span><span id="stattxt">Connecting…</span></div> | |
| 258 | +<div class="grid g-metric"> | |
| 259 | + <div class="card"><div class="k">CPU Load</div><div class="v"><span id="cpu">–</span><small>%</small></div> | |
| 260 | + <div class="bar"><i id="cpubar" style="background:var(--acc)"></i></div> | |
| 261 | + <div class="sub"><span id="cpuus">–</span>% user · <span id="cpusy">–</span>% sys · <span id="ncpu">–</span> cores</div></div> | |
| 262 | + <div class="card"><div class="k">Memory</div><div class="v"><span id="mem">–</span><small>%</small></div> | |
| 263 | + <div class="bar"><i id="membar" style="background:#2563eb"></i></div> | |
| 264 | + <div class="sub"><span id="memu">–</span> / <span id="memt">–</span> GB used</div></div> | |
| 265 | + <div class="card"><div class="k">Disk /</div><div class="v"><span id="disk">–</span><small>%</small></div> | |
| 266 | + <div class="bar"><i id="diskbar" style="background:#0891b2"></i></div> | |
| 267 | + <div class="sub"><span id="dfree">–</span> GB free of <span id="dtot">–</span> GB</div></div> | |
| 268 | + <div class="card"><div class="k">Load Average</div><div class="v" style="font-size:24px" id="load">–</div> | |
| 269 | + <div class="sub">1 / 5 / 15 min</div></div> | |
| 270 | + <div class="card"><div class="k">Uptime</div><div class="v" style="font-size:20px" id="uptime">–</div> | |
| 271 | + <div class="sub">processes: <span id="nproc">–</span></div></div> | |
| 272 | + <div class="card"><div class="k">Health</div><div class="v" style="font-size:20px" id="health">–</div> | |
| 273 | + <div class="sub">swap <span id="swap">–</span> · <span id="ipv">–</span></div></div> | |
| 274 | +</div> | |
| 275 | + | |
| 276 | +<div class="grid g-two" style="margin-top:12px"> | |
| 277 | + <div class="card"><div class="k">Top processes — CPU</div><div id="topcpu" style="margin-top:8px"></div></div> | |
| 278 | + <div class="card"><div class="k">Top processes — Memory</div><div id="topmem" style="margin-top:8px"></div></div> | |
| 279 | +</div> | |
| 280 | + | |
| 281 | +<h2>Hardware specifications (capacity)</h2> | |
| 282 | +<div class="grid g-spec"> | |
| 283 | + <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> | |
| 284 | + <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> | |
| 285 | + <div class="card"><div class="k">GPU</div><div class="v">{n['gpu']}<small> cores</small></div></div> | |
| 286 | + <div class="card"><div class="k">Neural Engine</div><div class="v">{n['ne']}<small> cores</small></div></div> | |
| 287 | + <div class="card"><div class="k">Storage</div><div class="v" style="font-size:19px">{esc(storage)}</div></div> | |
| 288 | + <div class="card"><div class="k">Tier · Role</div><div class="v" style="font-size:19px">{esc(n['tier'])} · {esc(n['role'])}</div></div> | |
| 289 | +</div> | |
| 290 | + | |
| 291 | +<h2>System</h2> | |
| 292 | +<div class="card"> | |
| 293 | + <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> | |
| 294 | + <div class="row"><span>Model identifier</span><b>{esc(n['modelid'])}</b></div> | |
| 295 | + <div class="row"><span>Serial number</span><b>{esc(n['serial'])}</b></div> | |
| 296 | + <div class="row"><span>Local IP</span><b id="s_ip">–</b></div> | |
| 297 | + <div class="row"><span>Hostname</span><b>{esc(n['fqdn'])}</b></div> | |
| 298 | +</div> | |
| 299 | + | |
| 300 | +<h2>Maintenance schedule</h2> | |
| 301 | +<p class="muted" style="margin:0 4px 12px">Tick each completed task, set the date and a note. Saved on m2u64 — shared across everyone who opens this page.</p> | |
| 302 | +<div class="mwrap">{rows}</div> | |
| 303 | +<div class="grid g-two" style="margin-top:12px"> | |
| 304 | + <div class="card"><div class="k">Next maintenance due</div><input type="date" id="next" style="margin-top:9px"></div> | |
| 305 | + <div class="card"><div class="k">Log / notes</div><textarea id="log" placeholder="Notes, incidents, parts replaced…"></textarea></div> | |
| 306 | +</div> | |
| 307 | +<div class="savebar"><button class="btn" onclick="saveM()">Save maintenance</button> | |
| 308 | + <span class="saved" id="saved">Saved ✓</span><span class="muted" id="savedat"></span></div> | |
| 309 | + | |
| 310 | +<div class="foot">MacLustr node page · auto-refreshes every 5s · <a href="/">cluster overview</a></div> | |
| 311 | +</div> | |
| 312 | +<script> | |
| 313 | +const SLUG="{slug}"; | |
| 314 | +const $=id=>document.getElementById(id); | |
| 315 | +function bar(id,p){{const e=$(id);e.style.width=Math.min(100,p)+'%'; | |
| 316 | + e.style.background=p>90?'var(--red)':p>75?'var(--amber)':e.dataset.c||e.style.background; | |
| 317 | + if(!e.dataset.c)e.dataset.c=e.style.background;}} | |
| 318 | +function plist(el,arr,unit){{el.innerHTML=(arr||[]).map(p=> | |
| 319 | + `<div class="proc"><span class="nm">${{p.name}}</span><span class="pc">${{p.v}}${{unit}}</span></div>`).join('')||'<div class="muted">—</div>';}} | |
| 320 | +async function refresh(){{ | |
| 321 | + try{{const d=await (await fetch('/api/'+SLUG+'/live')).json(); | |
| 322 | + if(!d.online){{$('dot').className='dot off';$('stattxt').textContent='Offline — '+(d.error||'no response');return;}} | |
| 323 | + $('dot').className='dot on'; | |
| 324 | + $('cpu').textContent=d.cpu_pct;bar('cpubar',d.cpu_pct);$('cpuus').textContent=d.cpu_user;$('cpusy').textContent=d.cpu_sys;$('ncpu').textContent=d.ncpu; | |
| 325 | + $('mem').textContent=d.mem_pct;bar('membar',d.mem_pct);$('memu').textContent=d.mem_used_gb;$('memt').textContent=d.mem_total_gb; | |
| 326 | + $('disk').textContent=d.disk_pct;bar('diskbar',d.disk_pct);$('dfree').textContent=d.disk_free_gb;$('dtot').textContent=d.disk_total_gb; | |
| 327 | + $('load').textContent=(d.load||[]).join(' '); | |
| 328 | + $('uptime').textContent=d.uptime;$('nproc').textContent=d.nproc; | |
| 329 | + $('health').textContent=d.thermal||'OK'; | |
| 330 | + $('swap').textContent=d.swap_used+'M';$('ipv').textContent=d.ip||''; | |
| 331 | + $('s_ip').textContent=d.ip||'–';$('s_os').textContent=d.osv;$('s_build').textContent=d.build; | |
| 332 | + plist($('topcpu'),d.topcpu,'%');plist($('topmem'),d.topmem,' MB'); | |
| 333 | + $('stattxt').textContent='Live · updated '+new Date(d.ts*1000).toLocaleTimeString(); | |
| 334 | + }}catch(e){{$('dot').className='dot off';$('stattxt').textContent='Connection error';}} | |
| 335 | +}} | |
| 336 | +async function loadM(){{const d=await (await fetch('/api/'+SLUG+'/maintenance')).json();const t=d.tasks||{{}}; | |
| 337 | + document.querySelectorAll('.mck').forEach(c=>c.checked=!!(t[c.dataset.i]||{{}}).done); | |
| 338 | + document.querySelectorAll('.mdate').forEach(c=>c.value=(t[c.dataset.i]||{{}}).date||''); | |
| 339 | + document.querySelectorAll('.mnote').forEach(c=>c.value=(t[c.dataset.i]||{{}}).note||''); | |
| 340 | + $('next').value=d.next||'';$('log').value=d.log||''; | |
| 341 | + if(d.saved_at)$('savedat').textContent='last saved '+d.saved_at;}} | |
| 342 | +async function saveM(){{const tasks={{}}; | |
| 343 | + document.querySelectorAll('.mck').forEach(c=>tasks[c.dataset.i]={{done:c.checked}}); | |
| 344 | + document.querySelectorAll('.mdate').forEach(c=>tasks[c.dataset.i].date=c.value); | |
| 345 | + document.querySelectorAll('.mnote').forEach(c=>tasks[c.dataset.i].note=c.value); | |
| 346 | + const d=await (await fetch('/api/'+SLUG+'/maintenance',{{method:'POST',headers:{{'Content-Type':'application/json'}}, | |
| 347 | + body:JSON.stringify({{tasks,next:$('next').value,log:$('log').value}})}})).json(); | |
| 348 | + const s=$('saved');s.style.opacity=1;setTimeout(()=>s.style.opacity=0,1800); | |
| 349 | + if(d.saved_at)$('savedat').textContent='last saved '+d.saved_at;}} | |
| 350 | +refresh();loadM();setInterval(refresh,5000); | |
| 351 | +</script></body></html>""" | |
| 352 | + | |
| 353 | +def index_page(): | |
| 354 | + tc = sum(n["ncpu"] for n in NODES); tg = sum(n["gpu"] for n in NODES); tr = sum(n["mem"] for n in NODES) | |
| 355 | + cards = "".join( | |
| 356 | + f'<a class="card" style="display:block;color:inherit" href="/{n["slug"]}">' | |
| 357 | + f'<div style="display:flex;align-items:center;gap:8px"><span class="dot" id="d_{n["slug"]}"></span>' | |
| 358 | + f'<b style="font-size:19px">{esc(n["alias"])}</b>' | |
| 359 | + f'<span class="badge" style="background:{n["tcolor"]};font-size:11px;padding:3px 9px;margin-left:auto">{esc(n["tier"])}</span></div>' | |
| 360 | + f'<div class="sub" style="margin-top:7px">{esc(n["chip"])}</div>' | |
| 361 | + f'<div class="sub">{n["ncpu"]} cores · {n["mem"]} GB · {n["gpu"]} GPU</div>' | |
| 362 | + f'<div class="sub" id="s_{n["slug"]}" style="margin-top:7px;font-variant-numeric:tabular-nums">CPU – · MEM –</div>' | |
| 363 | + f'</a>' | |
| 364 | + for n in NODES) | |
| 365 | + return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| 366 | +<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| 367 | +<meta name="theme-color" content="#f2f3f7"><title>MacLustr · Cluster</title><style>{CSS}</style></head><body><div class="wrap"> | |
| 368 | +<div class="bar-top"><img class="logo" src="/logo.svg"><span class="brand">MACLUSTR</span></div> | |
| 369 | +<div class="hero"><div><h1 style="font-size:42px">Cluster</h1> | |
| 370 | +<div class="meta">{len(NODES)} nodes · {tc} CPU cores · {tr} GB RAM · {tg} GPU cores</div></div></div> | |
| 371 | +<h2>Nodes</h2> | |
| 372 | +<div class="grid g-spec">{cards}</div> | |
| 373 | +<div class="foot">Scan a label QR to open a node's live page.</div> | |
| 374 | +</div><script> | |
| 375 | +const S={json.dumps([n["slug"] for n in NODES])},$=id=>document.getElementById(id); | |
| 376 | +async function poll(s){{try{{const d=await (await fetch('/api/'+s+'/live')).json(); | |
| 377 | + const dot=$('d_'+s),t=$('s_'+s); | |
| 378 | + if(d.online){{dot.className='dot on';t.textContent='CPU '+d.cpu_pct+'% · MEM '+d.mem_pct+'%';}} | |
| 379 | + else{{dot.className='dot off';t.textContent='offline';}}}}catch(e){{}}}} | |
| 380 | +function all(){{S.forEach((s,i)=>setTimeout(()=>poll(s),i*100));}} | |
| 381 | +all();setInterval(all,8000); | |
| 382 | +</script></body></html>""" | |
| 383 | + | |
| 384 | +LOGO_PATH = os.path.join(HERE, "logo.svg") | |
| 385 | +LOGO = open(LOGO_PATH).read() if os.path.exists(LOGO_PATH) else "<svg/>" | |
| 386 | + | |
| 387 | +class H(BaseHTTPRequestHandler): | |
| 388 | + def log_message(self, *a): pass | |
| 389 | + def _send(self, code, body, ctype="text/html; charset=utf-8"): | |
| 390 | + b = body.encode() if isinstance(body, str) else body | |
| 391 | + self.send_response(code) | |
| 392 | + self.send_header("Content-Type", ctype) | |
| 393 | + self.send_header("Content-Length", str(len(b))) | |
| 394 | + self.send_header("Cache-Control", "no-store") | |
| 395 | + self.end_headers() | |
| 396 | + try: self.wfile.write(b) | |
| 397 | + except (BrokenPipeError, ConnectionResetError): pass | |
| 398 | + def do_GET(self): | |
| 399 | + path = urlparse(self.path).path.strip("/") | |
| 400 | + if path == "": return self._send(200, index_page()) | |
| 401 | + if path in ("health", "healthz"): return self._send(200, "ok", "text/plain") | |
| 402 | + if path == "logo.svg": return self._send(200, LOGO, "image/svg+xml") | |
| 403 | + if path.startswith("api/"): | |
| 404 | + parts = path.split("/") | |
| 405 | + if len(parts) == 3 and parts[1].lower() in BY_SLUG: | |
| 406 | + slug = parts[1].lower() | |
| 407 | + if parts[2] == "live": return self._send(200, json.dumps(live(slug)), "application/json") | |
| 408 | + if parts[2] == "maintenance": return self._send(200, json.dumps(get_maint(slug)), "application/json") | |
| 409 | + return self._send(404, "{}", "application/json") | |
| 410 | + if path.lower() in BY_SLUG: return self._send(200, node_page(BY_SLUG[path.lower()])) | |
| 411 | + return self._send(404, "<h1>404</h1><p><a href='/'>cluster</a></p>") | |
| 412 | + def do_POST(self): | |
| 413 | + parts = urlparse(self.path).path.strip("/").split("/") | |
| 414 | + if len(parts) == 3 and parts[0] == "api" and parts[2] == "maintenance" and parts[1].lower() in BY_SLUG: | |
| 415 | + try: | |
| 416 | + n = int(self.headers.get("Content-Length", 0)) | |
| 417 | + payload = json.loads(self.rfile.read(n) or b"{}") | |
| 418 | + return self._send(200, json.dumps(set_maint(parts[1].lower(), payload)), "application/json") | |
| 419 | + except Exception as e: | |
| 420 | + return self._send(400, json.dumps({"error": str(e)}), "application/json") | |
| 421 | + return self._send(404, "{}", "application/json") | |
| 422 | + | |
| 423 | +if __name__ == "__main__": | |
| 424 | + srv = ThreadingHTTPServer(("0.0.0.0", PORT), H) | |
| 425 | + print(f"MacLustr node pages on :{PORT} ({len(NODES)} nodes, self={SELF_SLUG})") | |
| 426 | + srv.serve_forever() | |
added
server.py.bak.20260616-135452
+489 −0
@@ -0,0 +1,489 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""MacLustr node pages — live metrics + maintenance schedule. | |
| 3 | +Stdlib only (Python 3.9+). Runs on m2u64, serves http://0.0.0.0:8003. | |
| 4 | +Each node label QR -> https://node.maclustr.io/<slug>.""" | |
| 5 | +import json, os, subprocess, threading, time, html | |
| 6 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| 7 | +from urllib.parse import urlparse | |
| 8 | + | |
| 9 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
| 10 | +PORT = 8003 | |
| 11 | +SELF_SLUG = "m2u64" # this server runs ON m2u64 -> collect locally | |
| 12 | +KEY = os.path.expanduser("~/.ssh/id_ed25519") | |
| 13 | +MAINT_FILE = os.path.join(HERE, "maintenance.json") | |
| 14 | + | |
| 15 | +NODES = json.load(open(os.path.join(HERE, "nodes.json"))) | |
| 16 | +BY_SLUG = {n["slug"]: n for n in NODES} | |
| 17 | + | |
| 18 | +# recurring maintenance schedule (checklist) — (title, frequency, detail) | |
| 19 | +TASKS = [ | |
| 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 | +] | |
| 41 | + | |
| 42 | +# dropdown vocabularies for the maintenance form | |
| 43 | +STATUS_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 | +] | |
| 52 | +STATUS_COLOR = { | |
| 53 | + "done": "#16a34a", "progress": "#2563eb", "scheduled": "#7c3aed", | |
| 54 | + "skip": "#9ca3af", "attention": "#d97706", "failed": "#dc2626", "": "transparent", | |
| 55 | +} | |
| 56 | +CONDITION_OPTS = [ | |
| 57 | + ("", "— Overall condition —"), | |
| 58 | + ("excellent", "🟢 Excellent"), | |
| 59 | + ("good", "🟢 Good"), | |
| 60 | + ("fair", "🟡 Fair"), | |
| 61 | + ("poor", "🟠 Poor — service soon"), | |
| 62 | + ("critical", "🔴 Needs service now"), | |
| 63 | +] | |
| 64 | +BY_SUGGEST = ["SPB", "Remote", "On-site"] # datalist suggestions for "performed by" | |
| 65 | + | |
| 66 | +# ---- live metrics collector (runs on the target node) ---- | |
| 67 | +COLLECT = r''' | |
| 68 | +PAGESIZE=$(sysctl -n hw.pagesize 2>/dev/null || echo 16384) | |
| 69 | +CPULINE=$(top -l 2 -n 0 2>/dev/null | grep "CPU usage" | tail -1) | |
| 70 | +USR=$(echo "$CPULINE" | sed -E 's/.*: ([0-9.]+)% user.*/\1/') | |
| 71 | +SYS=$(echo "$CPULINE" | sed -E 's/.*user, ([0-9.]+)% sys.*/\1/') | |
| 72 | +IDLE=$(echo "$CPULINE" | sed -E 's/.*[, ]([0-9.]+)% idle.*/\1/') | |
| 73 | +NCPU=$(sysctl -n hw.ncpu 2>/dev/null) | |
| 74 | +LOAD=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}') | |
| 75 | +MEMTOTAL=$(sysctl -n hw.memsize 2>/dev/null) | |
| 76 | +VM=$(vm_stat 2>/dev/null) | |
| 77 | +g(){ echo "$VM" | awk -F: "/$1/{gsub(/[^0-9]/,\"\",\$2); print \$2}"; } | |
| 78 | +ACTIVE=$(g "Pages active"); WIRED=$(g "Pages wired down"); COMP=$(g "Pages occupied by compressor") | |
| 79 | +FREE=$(g "Pages free"); SPEC=$(g "Pages speculative") | |
| 80 | +DF=$(df -k / 2>/dev/null | awk 'NR==2{print $2","$3","$4","$5}') | |
| 81 | +SWAPT=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*total = ([0-9.]+)M.*/\1/') | |
| 82 | +SWAPU=$(sysctl -n vm.swapusage 2>/dev/null | sed -E 's/.*used = ([0-9.]+)M.*/\1/') | |
| 83 | +UP=$(uptime | sed -E 's/^.*up *//; s/, *[0-9]+ users?.*//; s/, *load.*//') | |
| 84 | +BOOT=$(sysctl -n kern.boottime 2>/dev/null | sed -E 's/.*sec = ([0-9]+).*/\1/') | |
| 85 | +NPROC=$(ps -A 2>/dev/null | wc -l | tr -d ' ') | |
| 86 | +TOP=$(ps -Aceo pcpu,comm -r 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') | |
| 87 | +TOPMEM=$(ps -Aceo rss,comm -m 2>/dev/null | awk 'NR>1 && NR<=6{printf "%s|%s;", $1, $2}') | |
| 88 | +IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) | |
| 89 | +THERM=$(pmset -g therm 2>/dev/null | sed -nE 's/.*CPU_Speed_Limit = ([0-9]+).*/\1/p') | |
| 90 | +BATT=$(pmset -g batt 2>/dev/null | grep -oE '[0-9]+%' | head -1) | |
| 91 | +OSV=$(sw_vers -productVersion 2>/dev/null) | |
| 92 | +BUILD=$(sw_vers -buildVersion 2>/dev/null) | |
| 93 | +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" \ | |
| 94 | + "$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" | |
| 95 | +''' | |
| 96 | + | |
| 97 | +_cache = {} | |
| 98 | +_cache_lock = threading.Lock() | |
| 99 | +TTL = 4 | |
| 100 | + | |
| 101 | +def _run(slug, node): | |
| 102 | + if slug == SELF_SLUG: | |
| 103 | + cmd = ["bash", "-c", COLLECT]; inp = None | |
| 104 | + else: | |
| 105 | + cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", | |
| 106 | + "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=8", | |
| 107 | + "-i", KEY, node["fqdn"], "bash -s"]; inp = COLLECT | |
| 108 | + try: | |
| 109 | + p = subprocess.run(cmd, input=inp, capture_output=True, text=True, timeout=20) | |
| 110 | + if p.returncode != 0: | |
| 111 | + return {"online": False, "error": (p.stderr or "ssh failed").strip()[:200]} | |
| 112 | + kv = {} | |
| 113 | + for line in p.stdout.splitlines(): | |
| 114 | + if "=" in line: | |
| 115 | + k, v = line.split("=", 1); kv[k] = v | |
| 116 | + return _parse(kv) | |
| 117 | + except subprocess.TimeoutExpired: | |
| 118 | + return {"online": False, "error": "timeout"} | |
| 119 | + except Exception as e: | |
| 120 | + return {"online": False, "error": str(e)[:200]} | |
| 121 | + | |
| 122 | +def _f(x, d=0.0): | |
| 123 | + try: return float(x) | |
| 124 | + except: return d | |
| 125 | + | |
| 126 | +def _procs(s): | |
| 127 | + out = [] | |
| 128 | + for chunk in (s or "").split(";"): | |
| 129 | + if "|" in chunk: | |
| 130 | + a, b = chunk.split("|", 1); out.append((a, b)) | |
| 131 | + return out | |
| 132 | + | |
| 133 | +def _parse(kv): | |
| 134 | + pg = _f(kv.get("PAGESIZE"), 16384) | |
| 135 | + memtot = _f(kv.get("MEMTOTAL")) | |
| 136 | + app = _f(kv.get("ACTIVE")) * pg | |
| 137 | + wired = _f(kv.get("WIRED")) * pg | |
| 138 | + comp = _f(kv.get("COMP")) * pg | |
| 139 | + used = app + wired + comp | |
| 140 | + idle = _f(kv.get("IDLE")) | |
| 141 | + df = (kv.get("DF") or "0,0,0,0%").split(",") | |
| 142 | + dtot = _f(df[0]) * 1024 if len(df) > 0 else 0 | |
| 143 | + dused = _f(df[1]) * 1024 if len(df) > 1 else 0 | |
| 144 | + dfree = _f(df[2]) * 1024 if len(df) > 2 else 0 | |
| 145 | + dpct = _f("".join(ch for ch in (df[3] if len(df) > 3 else "0") if ch.isdigit() or ch == ".")) | |
| 146 | + load = (kv.get("LOAD") or "").split() | |
| 147 | + g = lambda b: round(b / 1e9, 1) | |
| 148 | + topcpu = [{"v": a, "name": b} for a, b in _procs(kv.get("TOP"))][:5] | |
| 149 | + topmem = [{"v": str(round(_f(a) / 1024)), "name": b} for a, b in _procs(kv.get("TOPMEM"))][:5] | |
| 150 | + therm = kv.get("THERM", "") | |
| 151 | + return { | |
| 152 | + "online": True, | |
| 153 | + "cpu_pct": round(max(0.0, min(100.0, 100.0 - idle)), 1), | |
| 154 | + "cpu_user": round(_f(kv.get("USR")), 1), | |
| 155 | + "cpu_sys": round(_f(kv.get("SYS")), 1), | |
| 156 | + "ncpu": kv.get("NCPU", "?"), | |
| 157 | + "mem_pct": round(used / memtot * 100, 1) if memtot else 0, | |
| 158 | + "mem_used_gb": g(used), "mem_total_gb": g(memtot), | |
| 159 | + "mem_app_gb": g(app), "mem_wired_gb": g(wired), "mem_comp_gb": g(comp), | |
| 160 | + "disk_pct": round(dpct, 1), | |
| 161 | + "disk_used_gb": g(dused), "disk_free_gb": g(dfree), "disk_total_gb": g(dtot), | |
| 162 | + "load": load[:3], | |
| 163 | + "swap_used": kv.get("SWAPU", "0"), "swap_total": kv.get("SWAPT", "0"), | |
| 164 | + "uptime": (kv.get("UPTIME") or "?").strip(), | |
| 165 | + "nproc": kv.get("NPROC", "?"), | |
| 166 | + "osv": kv.get("OSV", "?"), "build": kv.get("BUILD", "?"), | |
| 167 | + "ip": kv.get("IP", ""), | |
| 168 | + "batt": kv.get("BATT", ""), | |
| 169 | + "thermal": "Throttled (" + therm + "%)" if (therm and therm != "100") else ("Nominal" if therm else ""), | |
| 170 | + "topcpu": topcpu, "topmem": topmem, | |
| 171 | + "ts": int(time.time()), | |
| 172 | + } | |
| 173 | + | |
| 174 | +def live(slug): | |
| 175 | + node = BY_SLUG[slug]; now = time.time() | |
| 176 | + with _cache_lock: | |
| 177 | + c = _cache.get(slug) | |
| 178 | + if c and now - c[0] < TTL: | |
| 179 | + return c[1] | |
| 180 | + data = _run(slug, node) | |
| 181 | + with _cache_lock: | |
| 182 | + _cache[slug] = (now, data) | |
| 183 | + return data | |
| 184 | + | |
| 185 | +# ---- maintenance persistence ---- | |
| 186 | +_maint_lock = threading.Lock() | |
| 187 | +def load_maint(): | |
| 188 | + try: return json.load(open(MAINT_FILE)) | |
| 189 | + except: return {} | |
| 190 | +def save_maint(d): | |
| 191 | + with _maint_lock: | |
| 192 | + json.dump(d, open(MAINT_FILE, "w"), indent=2) | |
| 193 | +def get_maint(slug): | |
| 194 | + return load_maint().get(slug, {"tasks": {}, "next": "", "log": "", | |
| 195 | + "condition": "", "inspected_by": ""}) | |
| 196 | +def set_maint(slug, payload): | |
| 197 | + d = load_maint() | |
| 198 | + d[slug] = {"tasks": payload.get("tasks", {}), "next": payload.get("next", ""), | |
| 199 | + "log": payload.get("log", ""), | |
| 200 | + "condition": payload.get("condition", ""), | |
| 201 | + "inspected_by": payload.get("inspected_by", ""), | |
| 202 | + "saved_at": time.strftime("%Y-%m-%d %H:%M")} | |
| 203 | + save_maint(d); return d[slug] | |
| 204 | + | |
| 205 | +def esc(s): return html.escape(str(s)) | |
| 206 | + | |
| 207 | +CSS = """ | |
| 208 | +:root{--bg:#f2f3f7;--card:#fff;--ink:#1d1d1f;--mut:#6b7280;--line:#e6e8ef;--acc:#7c3aed; | |
| 209 | +--green:#16a34a;--amber:#d97706;--red:#dc2626;--shadow:0 1px 3px rgba(16,24,40,.06),0 1px 2px rgba(16,24,40,.04)} | |
| 210 | +*{box-sizing:border-box;margin:0;padding:0} | |
| 211 | +html{-webkit-text-size-adjust:100%} | |
| 212 | +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} | |
| 213 | +a{color:var(--acc);text-decoration:none} | |
| 214 | +.wrap{max-width:1080px;margin:0 auto;padding:18px 16px 64px} | |
| 215 | +.bar-top{display:flex;align-items:center;gap:12px;margin-bottom:18px} | |
| 216 | +.bar-top .logo{width:42px;height:42px;border-radius:11px;box-shadow:var(--shadow)} | |
| 217 | +.bar-top .brand{font-weight:800;letter-spacing:.4px;font-size:13px;color:var(--mut)} | |
| 218 | +.bar-top .back{margin-left:auto;font-size:13px;font-weight:600;color:var(--mut)} | |
| 219 | +.hero{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:var(--shadow); | |
| 220 | +display:flex;align-items:center;gap:16px;flex-wrap:wrap} | |
| 221 | +.hero h1{font-size:clamp(34px,9vw,58px);font-weight:850;letter-spacing:-1.5px;line-height:1} | |
| 222 | +.hero .meta{color:var(--mut);font-size:14px;margin-top:6px} | |
| 223 | +.badge{padding:7px 16px;border-radius:10px;color:#fff;font-weight:800;font-size:16px;margin-left:auto} | |
| 224 | +.chiptag{display:inline-block;background:#111;color:#fff;font-weight:800;font-size:13px;padding:4px 12px;border-radius:8px;margin-top:8px} | |
| 225 | +.status{display:flex;align-items:center;gap:8px;margin:26px 2px 12px;font-size:13px;color:var(--mut)} | |
| 226 | +.dot{width:10px;height:10px;border-radius:50%;background:#9ca3af;display:inline-block} | |
| 227 | +.dot.on{background:var(--green);box-shadow:0 0 0 4px rgba(22,163,74,.15)} | |
| 228 | +.dot.off{background:var(--red);box-shadow:0 0 0 4px rgba(220,38,38,.15)} | |
| 229 | +h2{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--mut);margin:30px 4px 12px} | |
| 230 | +.grid{display:grid;gap:12px} | |
| 231 | +.g-metric{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))} | |
| 232 | +.g-spec{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))} | |
| 233 | +.g-two{grid-template-columns:1fr 1fr} | |
| 234 | +@media(max-width:560px){.g-two{grid-template-columns:1fr}} | |
| 235 | +.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 16px;box-shadow:var(--shadow)} | |
| 236 | +.k{font-size:11px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)} | |
| 237 | +.v{font-size:30px;font-weight:850;margin-top:4px;font-variant-numeric:tabular-nums} | |
| 238 | +.v small{font-size:14px;color:var(--mut);font-weight:700} | |
| 239 | +.sub{color:var(--mut);font-size:12.5px;margin-top:3px} | |
| 240 | +.bar{height:8px;border-radius:5px;background:#eceef4;overflow:hidden;margin-top:10px} | |
| 241 | +.bar>i{display:block;height:100%;border-radius:5px;width:0;transition:width .6s ease} | |
| 242 | +.row{display:flex;justify-content:space-between;font-size:13.5px;padding:7px 0;border-bottom:1px solid var(--line)} | |
| 243 | +.row:last-child{border-bottom:0} | |
| 244 | +.row b{font-weight:700} | |
| 245 | +.proc{display:flex;justify-content:space-between;font-size:13px;padding:5px 0;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums} | |
| 246 | +.proc:last-child{border-bottom:0} | |
| 247 | +.proc .nm{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:72%} | |
| 248 | +.proc .pc{color:var(--mut);font-weight:700} | |
| 249 | +.mwrap{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden} | |
| 250 | +.mhead{display:grid;grid-template-columns:150px 1fr 140px 130px 1.1fr;gap:12px;padding:11px 16px;border-bottom:1px solid var(--line); | |
| 251 | +background:#fafafe;font-size:10.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--mut)} | |
| 252 | +.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} | |
| 253 | +.mrow:last-child{border-bottom:0} | |
| 254 | +.mrow .t{font-weight:600;font-size:14.5px} | |
| 255 | +.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} | |
| 256 | +.mrow .det{font-size:12px;color:var(--mut);margin-top:5px;line-height:1.4} | |
| 257 | +@media(max-width:620px){.mhead{display:none}.mrow{grid-template-columns:1fr;row-gap:9px}} | |
| 258 | +input[type=checkbox]{width:22px;height:22px;accent-color:var(--green);cursor:pointer} | |
| 259 | +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} | |
| 260 | +input:focus,textarea:focus,select:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,58,237,.12)} | |
| 261 | +textarea{min-height:80px;resize:vertical} | |
| 262 | +select{-webkit-appearance:none;appearance:none;cursor:pointer;padding-right:30px; | |
| 263 | +background-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>"); | |
| 264 | +background-repeat:no-repeat;background-position:right 11px center} | |
| 265 | +.savebar{display:flex;align-items:center;gap:14px;margin-top:16px;flex-wrap:wrap} | |
| 266 | +.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)} | |
| 267 | +.btn:active{transform:translateY(1px)} | |
| 268 | +.saved{color:var(--green);font-weight:700;opacity:0;transition:opacity .3s} | |
| 269 | +.muted{color:var(--mut);font-size:13px} | |
| 270 | +.foot{margin-top:34px;color:var(--mut);font-size:12px;text-align:center} | |
| 271 | +""" | |
| 272 | + | |
| 273 | +def node_page(n): | |
| 274 | + slug = n["slug"]; tc = n["tcolor"] | |
| 275 | + storage = f'{n["internal"]} SSD' + (f' + {n["external"]} external' if n["external"] else "") | |
| 276 | + status_opts = "".join(f'<option value="{esc(v)}">{esc(lbl)}</option>' for v, lbl in STATUS_OPTS) | |
| 277 | + rows = "".join( | |
| 278 | + f'<div class="mrow">' | |
| 279 | + f'<select data-i="{i}" class="mstatus" onchange="colorRow(this)">{status_opts}</select>' | |
| 280 | + f'<div><div class="t">{esc(t)}</div><span class="freq">{esc(fr)}</span>' | |
| 281 | + f'<div class="det">{esc(det)}</div></div>' | |
| 282 | + f'<input type="date" data-i="{i}" class="md mdate">' | |
| 283 | + f'<input type="text" data-i="{i}" class="mby" list="bylist" placeholder="performed by">' | |
| 284 | + f'<input type="text" data-i="{i}" class="mn mnote" placeholder="note">' | |
| 285 | + f'</div>' | |
| 286 | + for i, (t, fr, det) in enumerate(TASKS)) | |
| 287 | + cond_opts = "".join(f'<option value="{esc(v)}">{esc(lbl)}</option>' for v, lbl in CONDITION_OPTS) | |
| 288 | + by_datalist = "".join(f'<option value="{esc(b)}">' for b in BY_SUGGEST) | |
| 289 | + return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| 290 | +<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| 291 | +<meta name="theme-color" content="#f2f3f7"> | |
| 292 | +<title>{esc(n['alias'])} · MacLustr</title><style>{CSS}</style></head><body><div class="wrap"> | |
| 293 | + | |
| 294 | +<div class="bar-top"><img class="logo" src="/logo.svg" alt=""> | |
| 295 | +<span class="brand">MACLUSTR · COMPUTE CLUSTER</span> | |
| 296 | +<a class="back" href="/">All nodes →</a></div> | |
| 297 | + | |
| 298 | +<div class="hero"> | |
| 299 | + <div> | |
| 300 | + <h1>{esc(n['alias'])}</h1> | |
| 301 | + <div class="meta">{esc(n['fqdn'])} · {esc(n['model'])} ({esc(n['modelid'])})</div> | |
| 302 | + <span class="chiptag">{esc(n['chip'])}</span> | |
| 303 | + </div> | |
| 304 | + <span class="badge" style="background:{tc}">{esc(n['tier'])}</span> | |
| 305 | +</div> | |
| 306 | + | |
| 307 | +<div class="status"><span id="dot" class="dot"></span><span id="stattxt">Connecting…</span></div> | |
| 308 | +<div class="grid g-metric"> | |
| 309 | + <div class="card"><div class="k">CPU Load</div><div class="v"><span id="cpu">–</span><small>%</small></div> | |
| 310 | + <div class="bar"><i id="cpubar" style="background:var(--acc)"></i></div> | |
| 311 | + <div class="sub"><span id="cpuus">–</span>% user · <span id="cpusy">–</span>% sys · <span id="ncpu">–</span> cores</div></div> | |
| 312 | + <div class="card"><div class="k">Memory</div><div class="v"><span id="mem">–</span><small>%</small></div> | |
| 313 | + <div class="bar"><i id="membar" style="background:#2563eb"></i></div> | |
| 314 | + <div class="sub"><span id="memu">–</span> / <span id="memt">–</span> GB used</div></div> | |
| 315 | + <div class="card"><div class="k">Disk /</div><div class="v"><span id="disk">–</span><small>%</small></div> | |
| 316 | + <div class="bar"><i id="diskbar" style="background:#0891b2"></i></div> | |
| 317 | + <div class="sub"><span id="dfree">–</span> GB free of <span id="dtot">–</span> GB</div></div> | |
| 318 | + <div class="card"><div class="k">Load Average</div><div class="v" style="font-size:24px" id="load">–</div> | |
| 319 | + <div class="sub">1 / 5 / 15 min</div></div> | |
| 320 | + <div class="card"><div class="k">Uptime</div><div class="v" style="font-size:20px" id="uptime">–</div> | |
| 321 | + <div class="sub">processes: <span id="nproc">–</span></div></div> | |
| 322 | + <div class="card"><div class="k">Health</div><div class="v" style="font-size:20px" id="health">–</div> | |
| 323 | + <div class="sub">swap <span id="swap">–</span> · <span id="ipv">–</span></div></div> | |
| 324 | +</div> | |
| 325 | + | |
| 326 | +<div class="grid g-two" style="margin-top:12px"> | |
| 327 | + <div class="card"><div class="k">Top processes — CPU</div><div id="topcpu" style="margin-top:8px"></div></div> | |
| 328 | + <div class="card"><div class="k">Top processes — Memory</div><div id="topmem" style="margin-top:8px"></div></div> | |
| 329 | +</div> | |
| 330 | + | |
| 331 | +<h2>Hardware specifications (capacity)</h2> | |
| 332 | +<div class="grid g-spec"> | |
| 333 | + <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> | |
| 334 | + <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> | |
| 335 | + <div class="card"><div class="k">GPU</div><div class="v">{n['gpu']}<small> cores</small></div></div> | |
| 336 | + <div class="card"><div class="k">Neural Engine</div><div class="v">{n['ne']}<small> cores</small></div></div> | |
| 337 | + <div class="card"><div class="k">Storage</div><div class="v" style="font-size:19px">{esc(storage)}</div></div> | |
| 338 | + <div class="card"><div class="k">Tier · Role</div><div class="v" style="font-size:19px">{esc(n['tier'])} · {esc(n['role'])}</div></div> | |
| 339 | +</div> | |
| 340 | + | |
| 341 | +<h2>System</h2> | |
| 342 | +<div class="card"> | |
| 343 | + <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> | |
| 344 | + <div class="row"><span>Model identifier</span><b>{esc(n['modelid'])}</b></div> | |
| 345 | + <div class="row"><span>Serial number</span><b>{esc(n['serial'])}</b></div> | |
| 346 | + <div class="row"><span>Local IP</span><b id="s_ip">–</b></div> | |
| 347 | + <div class="row"><span>Hostname</span><b>{esc(n['fqdn'])}</b></div> | |
| 348 | +</div> | |
| 349 | + | |
| 350 | +<h2>Maintenance schedule</h2> | |
| 351 | +<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> | |
| 352 | +<datalist id="bylist">{by_datalist}</datalist> | |
| 353 | +<div class="mwrap"> | |
| 354 | + <div class="mhead"><span>Status</span><span>Task</span><span>Date</span><span>By</span><span>Note</span></div> | |
| 355 | + {rows} | |
| 356 | +</div> | |
| 357 | +<div class="grid g-spec" style="margin-top:12px"> | |
| 358 | + <div class="card"><div class="k">Overall condition</div><select id="cond" style="margin-top:9px">{cond_opts}</select></div> | |
| 359 | + <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> | |
| 360 | + <div class="card"><div class="k">Next maintenance due</div><input type="date" id="next" style="margin-top:9px"></div> | |
| 361 | +</div> | |
| 362 | +<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> | |
| 363 | +<div class="savebar"><button class="btn" onclick="saveM()">Submit maintenance report</button> | |
| 364 | + <span class="saved" id="saved">Saved ✓</span><span class="muted" id="savedat"></span></div> | |
| 365 | + | |
| 366 | +<div class="foot">MacLustr node page · auto-refreshes every 5s · <a href="/">cluster overview</a></div> | |
| 367 | +</div> | |
| 368 | +<script> | |
| 369 | +const SLUG="{slug}"; | |
| 370 | +const $=id=>document.getElementById(id); | |
| 371 | +function bar(id,p){{const e=$(id);e.style.width=Math.min(100,p)+'%'; | |
| 372 | + e.style.background=p>90?'var(--red)':p>75?'var(--amber)':e.dataset.c||e.style.background; | |
| 373 | + if(!e.dataset.c)e.dataset.c=e.style.background;}} | |
| 374 | +function plist(el,arr,unit){{el.innerHTML=(arr||[]).map(p=> | |
| 375 | + `<div class="proc"><span class="nm">${{p.name}}</span><span class="pc">${{p.v}}${{unit}}</span></div>`).join('')||'<div class="muted">—</div>';}} | |
| 376 | +async function refresh(){{ | |
| 377 | + try{{const d=await (await fetch('/api/'+SLUG+'/live')).json(); | |
| 378 | + if(!d.online){{$('dot').className='dot off';$('stattxt').textContent='Offline — '+(d.error||'no response');return;}} | |
| 379 | + $('dot').className='dot on'; | |
| 380 | + $('cpu').textContent=d.cpu_pct;bar('cpubar',d.cpu_pct);$('cpuus').textContent=d.cpu_user;$('cpusy').textContent=d.cpu_sys;$('ncpu').textContent=d.ncpu; | |
| 381 | + $('mem').textContent=d.mem_pct;bar('membar',d.mem_pct);$('memu').textContent=d.mem_used_gb;$('memt').textContent=d.mem_total_gb; | |
| 382 | + $('disk').textContent=d.disk_pct;bar('diskbar',d.disk_pct);$('dfree').textContent=d.disk_free_gb;$('dtot').textContent=d.disk_total_gb; | |
| 383 | + $('load').textContent=(d.load||[]).join(' '); | |
| 384 | + $('uptime').textContent=d.uptime;$('nproc').textContent=d.nproc; | |
| 385 | + $('health').textContent=d.thermal||'OK'; | |
| 386 | + $('swap').textContent=d.swap_used+'M';$('ipv').textContent=d.ip||''; | |
| 387 | + $('s_ip').textContent=d.ip||'–';$('s_os').textContent=d.osv;$('s_build').textContent=d.build; | |
| 388 | + plist($('topcpu'),d.topcpu,'%');plist($('topmem'),d.topmem,' MB'); | |
| 389 | + $('stattxt').textContent='Live · updated '+new Date(d.ts*1000).toLocaleTimeString(); | |
| 390 | + }}catch(e){{$('dot').className='dot off';$('stattxt').textContent='Connection error';}} | |
| 391 | +}} | |
| 392 | +const SCOLOR={json.dumps(STATUS_COLOR)}; | |
| 393 | +function colorRow(sel){{const r=sel.closest('.mrow');if(r)r.style.borderLeftColor=SCOLOR[sel.value]||'transparent';}} | |
| 394 | +async function loadM(){{const d=await (await fetch('/api/'+SLUG+'/maintenance')).json();const t=d.tasks||{{}}; | |
| 395 | + document.querySelectorAll('.mstatus').forEach(c=>{{const v=t[c.dataset.i]||{{}}; | |
| 396 | + c.value=v.status||(v.done?'done':'');colorRow(c);}}); | |
| 397 | + document.querySelectorAll('.mdate').forEach(c=>c.value=(t[c.dataset.i]||{{}}).date||''); | |
| 398 | + document.querySelectorAll('.mby').forEach(c=>c.value=(t[c.dataset.i]||{{}}).by||''); | |
| 399 | + document.querySelectorAll('.mnote').forEach(c=>c.value=(t[c.dataset.i]||{{}}).note||''); | |
| 400 | + $('next').value=d.next||'';$('log').value=d.log||''; | |
| 401 | + $('cond').value=d.condition||'';$('by_all').value=d.inspected_by||''; | |
| 402 | + if(d.saved_at)$('savedat').textContent='last saved '+d.saved_at;}} | |
| 403 | +async function saveM(){{const tasks={{}}; | |
| 404 | + document.querySelectorAll('.mstatus').forEach(c=>tasks[c.dataset.i]={{status:c.value,done:c.value==='done'}}); | |
| 405 | + document.querySelectorAll('.mdate').forEach(c=>tasks[c.dataset.i].date=c.value); | |
| 406 | + document.querySelectorAll('.mby').forEach(c=>tasks[c.dataset.i].by=c.value); | |
| 407 | + document.querySelectorAll('.mnote').forEach(c=>tasks[c.dataset.i].note=c.value); | |
| 408 | + const d=await (await fetch('/api/'+SLUG+'/maintenance',{{method:'POST',headers:{{'Content-Type':'application/json'}}, | |
| 409 | + body:JSON.stringify({{tasks,next:$('next').value,log:$('log').value, | |
| 410 | + condition:$('cond').value,inspected_by:$('by_all').value}})}})).json(); | |
| 411 | + const s=$('saved');s.style.opacity=1;setTimeout(()=>s.style.opacity=0,1800); | |
| 412 | + if(d.saved_at)$('savedat').textContent='last saved '+d.saved_at;}} | |
| 413 | +refresh();loadM();setInterval(refresh,5000); | |
| 414 | +</script></body></html>""" | |
| 415 | + | |
| 416 | +def index_page(): | |
| 417 | + tc = sum(n["ncpu"] for n in NODES); tg = sum(n["gpu"] for n in NODES); tr = sum(n["mem"] for n in NODES) | |
| 418 | + cards = "".join( | |
| 419 | + f'<a class="card" style="display:block;color:inherit" href="/{n["slug"]}">' | |
| 420 | + f'<div style="display:flex;align-items:center;gap:8px"><span class="dot" id="d_{n["slug"]}"></span>' | |
| 421 | + f'<b style="font-size:19px">{esc(n["alias"])}</b>' | |
| 422 | + f'<span class="badge" style="background:{n["tcolor"]};font-size:11px;padding:3px 9px;margin-left:auto">{esc(n["tier"])}</span></div>' | |
| 423 | + f'<div class="sub" style="margin-top:7px">{esc(n["chip"])}</div>' | |
| 424 | + f'<div class="sub">{n["ncpu"]} cores · {n["mem"]} GB · {n["gpu"]} GPU</div>' | |
| 425 | + f'<div class="sub" id="s_{n["slug"]}" style="margin-top:7px;font-variant-numeric:tabular-nums">CPU – · MEM –</div>' | |
| 426 | + f'</a>' | |
| 427 | + for n in NODES) | |
| 428 | + return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| 429 | +<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| 430 | +<meta name="theme-color" content="#f2f3f7"><title>MacLustr · Cluster</title><style>{CSS}</style></head><body><div class="wrap"> | |
| 431 | +<div class="bar-top"><img class="logo" src="/logo.svg"><span class="brand">MACLUSTR</span></div> | |
| 432 | +<div class="hero"><div><h1 style="font-size:42px">Cluster</h1> | |
| 433 | +<div class="meta">{len(NODES)} nodes · {tc} CPU cores · {tr} GB RAM · {tg} GPU cores</div></div></div> | |
| 434 | +<h2>Nodes</h2> | |
| 435 | +<div class="grid g-spec">{cards}</div> | |
| 436 | +<div class="foot">Scan a label QR to open a node's live page.</div> | |
| 437 | +</div><script> | |
| 438 | +const S={json.dumps([n["slug"] for n in NODES])},$=id=>document.getElementById(id); | |
| 439 | +async function poll(s){{try{{const d=await (await fetch('/api/'+s+'/live')).json(); | |
| 440 | + const dot=$('d_'+s),t=$('s_'+s); | |
| 441 | + if(d.online){{dot.className='dot on';t.textContent='CPU '+d.cpu_pct+'% · MEM '+d.mem_pct+'%';}} | |
| 442 | + else{{dot.className='dot off';t.textContent='offline';}}}}catch(e){{}}}} | |
| 443 | +function all(){{S.forEach((s,i)=>setTimeout(()=>poll(s),i*100));}} | |
| 444 | +all();setInterval(all,8000); | |
| 445 | +</script></body></html>""" | |
| 446 | + | |
| 447 | +LOGO_PATH = os.path.join(HERE, "logo.svg") | |
| 448 | +LOGO = open(LOGO_PATH).read() if os.path.exists(LOGO_PATH) else "<svg/>" | |
| 449 | + | |
| 450 | +class H(BaseHTTPRequestHandler): | |
| 451 | + def log_message(self, *a): pass | |
| 452 | + def _send(self, code, body, ctype="text/html; charset=utf-8"): | |
| 453 | + b = body.encode() if isinstance(body, str) else body | |
| 454 | + self.send_response(code) | |
| 455 | + self.send_header("Content-Type", ctype) | |
| 456 | + self.send_header("Content-Length", str(len(b))) | |
| 457 | + self.send_header("Cache-Control", "no-store") | |
| 458 | + self.end_headers() | |
| 459 | + try: self.wfile.write(b) | |
| 460 | + except (BrokenPipeError, ConnectionResetError): pass | |
| 461 | + def do_GET(self): | |
| 462 | + path = urlparse(self.path).path.strip("/") | |
| 463 | + if path == "": return self._send(200, index_page()) | |
| 464 | + if path in ("health", "healthz"): return self._send(200, "ok", "text/plain") | |
| 465 | + if path == "logo.svg": return self._send(200, LOGO, "image/svg+xml") | |
| 466 | + if path.startswith("api/"): | |
| 467 | + parts = path.split("/") | |
| 468 | + if len(parts) == 3 and parts[1].lower() in BY_SLUG: | |
| 469 | + slug = parts[1].lower() | |
| 470 | + if parts[2] == "live": return self._send(200, json.dumps(live(slug)), "application/json") | |
| 471 | + if parts[2] == "maintenance": return self._send(200, json.dumps(get_maint(slug)), "application/json") | |
| 472 | + return self._send(404, "{}", "application/json") | |
| 473 | + if path.lower() in BY_SLUG: return self._send(200, node_page(BY_SLUG[path.lower()])) | |
| 474 | + return self._send(404, "<h1>404</h1><p><a href='/'>cluster</a></p>") | |
| 475 | + def do_POST(self): | |
| 476 | + parts = urlparse(self.path).path.strip("/").split("/") | |
| 477 | + if len(parts) == 3 and parts[0] == "api" and parts[2] == "maintenance" and parts[1].lower() in BY_SLUG: | |
| 478 | + try: | |
| 479 | + n = int(self.headers.get("Content-Length", 0)) | |
| 480 | + payload = json.loads(self.rfile.read(n) or b"{}") | |
| 481 | + return self._send(200, json.dumps(set_maint(parts[1].lower(), payload)), "application/json") | |
| 482 | + except Exception as e: | |
| 483 | + return self._send(400, json.dumps({"error": str(e)}), "application/json") | |
| 484 | + return self._send(404, "{}", "application/json") | |
| 485 | + | |
| 486 | +if __name__ == "__main__": | |
| 487 | + srv = ThreadingHTTPServer(("0.0.0.0", PORT), H) | |
| 488 | + print(f"MacLustr node pages on :{PORT} ({len(NODES)} nodes, self={SELF_SLUG})") | |
| 489 | + srv.serve_forever() | |
added
start-nodepages.sh
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +export PATH="/usr/bin:/bin:/usr/sbin:/sbin" | |
| 3 | +export PYTHONUNBUFFERED=1 | |
| 4 | +cd "$HOME/maclustr-node-pages" || exit 1 | |
| 5 | +exec /usr/bin/python3 server.py | |
| 6 | ||