| 1 | """Zion PM - mobile control desk (PWA) + JSON API. Zero deps (stdlib only). |
| 2 | |
| 3 | Serves a polished, installable mobile web app that reads/writes the same state/ files the |
| 4 | conductor uses: approve/reject outbox items, add tasks, see workers + activity. Pair with push |
| 5 | (bin/notify.sh) so you get pinged and tap through. |
| 6 | |
| 7 | Run: bash bin/pm-web.sh (wrapper; or: python3 bin/pm-web.py) |
| 8 | Env: PM_WEB_PORT (default 8780), PM_WEB_TOKEN (shared secret; if unset, binds localhost only) |
| 9 | Front it with Traefik + Authentik later for REDACTED-HOST. |
| 10 | """ |
| 11 | import json, os, time, http.server, socketserver, urllib.parse |
| 12 | |
| 13 | PM = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 14 | ST = os.path.join(PM, "state") |
| 15 | INBOX = os.path.join(PM, "inbox") |
| 16 | PORT = int(os.environ.get("PM_WEB_PORT", "8780")) |
| 17 | TOKEN = os.environ.get("PM_WEB_TOKEN", "") |
| 18 | BIND = "0.0.0.0" if TOKEN else "127.0.0.1" |
| 19 | |
| 20 | def _load(name, default): |
| 21 | try: |
| 22 | with open(os.path.join(ST, name)) as f: return json.load(f) |
| 23 | except Exception: return default |
| 24 | |
| 25 | def _save(name, obj): |
| 26 | p = os.path.join(ST, name); tmp = p + ".tmp" |
| 27 | with open(tmp, "w") as f: json.dump(obj, f, indent=2) |
| 28 | os.replace(tmp, p) |
| 29 | |
| 30 | def _ts(): return time.strftime("%Y-%m-%dT%H:%M:%S") |
| 31 | |
| 32 | def _log(event, **kw): |
| 33 | with open(os.path.join(ST, "activity.jsonl"), "a") as f: |
| 34 | f.write(json.dumps({"ts": _ts(), "event": event, **kw}) + "\n") |
| 35 | |
| 36 | def state_blob(): |
| 37 | tasks = _load("tasks.json", {"tasks": []})["tasks"] |
| 38 | outbox = _load("outbox.json", {"pending": [], "history": []}) |
| 39 | cond = _load("conductor_state.json", {}) |
| 40 | wdir = os.path.join(PM, "workers") |
| 41 | running = [] |
| 42 | if os.path.isdir(wdir): |
| 43 | running = [d for d in os.listdir(wdir) |
| 44 | if os.path.isdir(os.path.join(wdir, d)) |
| 45 | and not os.path.exists(os.path.join(wdir, d, "result.json"))] |
| 46 | act = [] |
| 47 | ap = os.path.join(ST, "activity.jsonl") |
| 48 | if os.path.exists(ap): |
| 49 | act = [json.loads(l) for l in open(ap).read().splitlines()[-15:] if l.strip()] |
| 50 | return { |
| 51 | "tasks": [t for t in tasks if t["status"] not in ("done", "cancelled")], |
| 52 | "approvals": outbox["pending"], |
| 53 | "workers": running, |
| 54 | "activity": list(reversed(act)), |
| 55 | "heartbeat": cond.get("last_tick"), |
| 56 | "ticks": cond.get("ticks", 0), |
| 57 | } |
| 58 | |
| 59 | def decide(oid, decision, reason=""): |
| 60 | o = _load("outbox.json", {"pending": [], "history": []}) |
| 61 | keep, hit = [], None |
| 62 | for p in o["pending"]: |
| 63 | if p["id"] == oid: hit = p |
| 64 | else: keep.append(p) |
| 65 | if not hit: return False |
| 66 | hit["decision"] = decision; hit["decided_at"] = _ts(); hit["reason"] = reason |
| 67 | o["pending"] = keep; o["history"].append(hit) |
| 68 | _save("outbox.json", o); _log("outbox_" + decision, id=oid, kind=hit.get("kind")) |
| 69 | return True |
| 70 | |
| 71 | def add_task(title, project=None, ttype="misc"): |
| 72 | import uuid |
| 73 | t = _load("tasks.json", {"tasks": []}) |
| 74 | tid = "t-" + uuid.uuid4().hex[:8] |
| 75 | t["tasks"].append({"id": tid, "title": title, "project": project, "type": ttype, |
| 76 | "status": "open", "created": _ts(), "artifacts": [], "worker": None}) |
| 77 | _save("tasks.json", t) |
| 78 | with open(os.path.join(INBOX, tid + ".task"), "w") as f: f.write(title + "\n") |
| 79 | _log("task_added", id=tid, title=title) |
| 80 | return tid |
| 81 | |
| 82 | INDEX = r"""<!doctype html><html lang=en><head><meta charset=utf-8> |
| 83 | <meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover"> |
| 84 | <meta name=theme-color content="#0b0f17"><link rel=manifest href=/manifest.json> |
| 85 | <meta name=apple-mobile-web-app-capable content=yes> |
| 86 | <meta name=apple-mobile-web-app-status-bar-style content=black-translucent> |
| 87 | <title>Zion PM</title><style> |
| 88 | :root{--bg: |
| 89 | --grn: |
| 90 | *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink); |
| 91 | font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif; |
| 92 | padding:env(safe-area-inset-top) 0 calc(env(safe-area-inset-bottom) + 80px)} |
| 93 | header{position:sticky;top:0;background:rgba(11,15,23,.92);backdrop-filter:blur(12px); |
| 94 | padding:16px 18px 12px;border-bottom:1px solid var(--line);z-index:5} |
| 95 | h1{font-size:20px;margin:0;font-weight:800;letter-spacing:-.3px} |
| 96 | .hb{color:var(--mut);font-size:12px;margin-top:3px} |
| 97 | .wrap{padding:14px 14px 0;max-width:680px;margin:0 auto} |
| 98 | .sec{margin-bottom:22px}.sec h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em; |
| 99 | color:var(--mut);margin:0 0 9px;font-weight:800} |
| 100 | .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;margin-bottom:10px} |
| 101 | .card.appr{border-left:3px solid var(--gold)} |
| 102 | .k{font-size:11px;font-weight:800;text-transform:uppercase;color:var(--gold);letter-spacing:.05em} |
| 103 | .pv{white-space:pre-wrap;color:var(--ink);font-size:13px;margin:7px 0 12px; |
| 104 | max-height:160px;overflow:auto;background: |
| 105 | .row{display:flex;gap:9px}.btn{flex:1;border:0;border-radius:11px;padding:12px;font-weight:800; |
| 106 | font-size:14px;color: |
| 107 | .task{display:flex;align-items:center;gap:10px}.dot{width:8px;height:8px;border-radius:9px;background:var(--blu);flex:0 0 auto} |
| 108 | .task .st{margin-left:auto;font-size:11px;color:var(--mut);text-transform:uppercase} |
| 109 | .proj{font-size:11px;color:var(--mut)} |
| 110 | .act{font-size:12px;color:var(--mut);padding:5px 0;border-bottom:1px solid var(--line)} |
| 111 | .empty{color:var(--mut);font-size:13px;padding:4px 2px} |
| 112 | .add{position:fixed;bottom:0;left:0;right:0;background:rgba(11,15,23,.95);backdrop-filter:blur(12px); |
| 113 | border-top:1px solid var(--line);padding:12px 14px calc(12px + env(safe-area-inset-bottom));display:flex;gap:8px} |
| 114 | .add input{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:12px; |
| 115 | padding:13px;color:var(--ink);font-size:15px}.add button{background:var(--blu);border:0;border-radius:12px; |
| 116 | padding:0 18px;color: |
| 117 | .chip{display:inline-block;background: |
| 118 | padding:2px 8px;font-size:11px;color:var(--mut);margin-left:6px} |
| 119 | </style></head><body> |
| 120 | <header><h1>Zion PM</h1><div class=hb id=hb>connecting…</div></header> |
| 121 | <div class=wrap> |
| 122 | <div class=sec><h2>Approvals</h2><div id=appr></div></div> |
| 123 | <div class=sec><h2>Today</h2><div id=tasks></div></div> |
| 124 | <div class=sec><h2>Workers</h2><div id=workers></div></div> |
| 125 | <div class=sec><h2>Activity</h2><div id=activity></div></div> |
| 126 | </div> |
| 127 | <div class=add><input id=ti placeholder="Tell the PM to do something…" |
| 128 | onkeydown="if(event.key==='Enter')addTask()"><button onclick=addTask()>+</button></div> |
| 129 | <script> |
| 130 | const TOK=new URLSearchParams(location.search).get('t')||''; |
| 131 | const H=TOK?{'X-PM-Token':TOK}:{}; |
| 132 | async function api(p,m='GET',b){const r=await fetch(p,{method:m,headers:{...H,'Content-Type':'application/json'}, |
| 133 | body:b?JSON.stringify(b):undefined});return r.json()} |
| 134 | function esc(s){return (s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]))} |
| 135 | async function load(){let s;try{s=await api('/api/state')}catch(e){document.getElementById('hb').textContent='offline';return} |
| 136 | document.getElementById('hb').textContent=`${s.ticks} ticks · last ${s.heartbeat||'never'} · ${s.workers.length} workers`; |
| 137 | const A=document.getElementById('appr'); |
| 138 | A.innerHTML=s.approvals.length?s.approvals.map(p=>`<div class="card appr"><div class=k>${esc(p.kind)}</div> |
| 139 | <div class=pv>${esc(p.preview)}</div><div class=row> |
| 140 | <button class="btn ok" onclick="act('${p.id}','approve')">Approve</button> |
| 141 | <button class="btn no" onclick="act('${p.id}','reject')">Reject</button></div></div>`).join(''):'<div class=empty>nothing waiting on you</div>'; |
| 142 | document.getElementById('tasks').innerHTML=s.tasks.length?s.tasks.map(t=>`<div class=card><div class=task> |
| 143 | <span class=dot></span><div><div>${esc(t.title)}</div><div class=proj>${esc(t.project||'')}</div></div> |
| 144 | <span class=st>${esc(t.status)}</span></div></div>`).join(''):'<div class=empty>no open tasks</div>'; |
| 145 | document.getElementById('workers').innerHTML=s.workers.length?s.workers.map(w=>`<div class=card>${esc(w)} <span class=chip>running</span></div>`).join(''):'<div class=empty>idle</div>'; |
| 146 | document.getElementById('activity').innerHTML=s.activity.map(a=>`<div class=act>${esc(a.ts)} · ${esc(a.event)} ${esc(a.title||a.id||'')}</div>`).join('')||'<div class=empty>-</div>'; |
| 147 | } |
| 148 | async function act(id,d){await api('/api/'+d,'POST',{id});load()} |
| 149 | async function addTask(){const i=document.getElementById('ti');if(!i.value.trim())return; |
| 150 | await api('/api/add','POST',{title:i.value.trim()});i.value='';load()} |
| 151 | load();setInterval(load,4000); |
| 152 | if('serviceWorker'in navigator)navigator.serviceWorker.register('/sw.js').catch(()=>{}); |
| 153 | </script></body></html>""" |
| 154 | |
| 155 | MANIFEST = json.dumps({ |
| 156 | "name": "Zion PM", "short_name": "Zion PM", "start_url": "/", "display": "standalone", |
| 157 | "background_color": "#0b0f17", "theme_color": "#0b0f17", |
| 158 | "icons": [{"src": "/icon.png", "sizes": "512x512", "type": "image/png"}] |
| 159 | }) |
| 160 | SW = "self.addEventListener('fetch',()=>{});" |
| 161 | |
| 162 | class H(http.server.BaseHTTPRequestHandler): |
| 163 | def _auth(self): |
| 164 | if not TOKEN: return True |
| 165 | return self.headers.get("X-PM-Token") == TOKEN |
| 166 | def _send(self, code, body, ctype="application/json"): |
| 167 | b = body.encode() if isinstance(body, str) else body |
| 168 | self.send_response(code); self.send_header("Content-Type", ctype) |
| 169 | self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b) |
| 170 | def log_message(self, *a): pass |
| 171 | def do_GET(self): |
| 172 | path = urllib.parse.urlparse(self.path).path |
| 173 | if path == "/": return self._send(200, INDEX, "text/html; charset=utf-8") |
| 174 | if path == "/manifest.json": return self._send(200, MANIFEST) |
| 175 | if path == "/sw.js": return self._send(200, SW, "application/javascript") |
| 176 | if path == "/icon.png": return self._send(200, b"", "image/png") |
| 177 | if path == "/api/state": |
| 178 | if not self._auth(): return self._send(401, '{"error":"auth"}') |
| 179 | return self._send(200, json.dumps(state_blob())) |
| 180 | self._send(404, '{"error":"nf"}') |
| 181 | def do_POST(self): |
| 182 | if not self._auth(): return self._send(401, '{"error":"auth"}') |
| 183 | path = urllib.parse.urlparse(self.path).path |
| 184 | n = int(self.headers.get("Content-Length", 0)) |
| 185 | body = json.loads(self.rfile.read(n) or b"{}") |
| 186 | if path == "/api/approve": return self._send(200, json.dumps({"ok": decide(body["id"], "approved")})) |
| 187 | if path == "/api/reject": return self._send(200, json.dumps({"ok": decide(body["id"], "rejected")})) |
| 188 | if path == "/api/add": |
| 189 | tid = add_task(body["title"], body.get("project"), body.get("type", "misc")) |
| 190 | return self._send(200, json.dumps({"ok": True, "id": tid})) |
| 191 | self._send(404, '{"error":"nf"}') |
| 192 | |
| 193 | class TS(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True |
| 194 | |
| 195 | if __name__ == "__main__": |
| 196 | print(f"[pm-web] http://{BIND}:{PORT} auth={'token' if TOKEN else 'LOCALHOST-ONLY (set PM_WEB_TOKEN)'}") |
| 197 | TS((BIND, PORT), H).serve_forever() |