SPB Git forge
2commits 1branches 0releases
2.5 MBsize
maindefault branch
18 h agolast push
Python 99.8%

adopt: état déployé sur M2U64 adopté comme source de vérité (remote-first, 2026-10-02)

mld committed yesterday (Oct 2, 2026)

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 &amp; heavy compute", 7.50, True,
424 + "M-series Ultra &amp; M4 Max with 64–96&nbsp;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&nbsp;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&nbsp;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 &amp; 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 &amp; 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 &amp; 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=>({{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}}[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