● Zion Boggan
repos/Zion Pm/archive/old-conductor-sketch/bin/pm-web.py
zionboggan.com ↗
197 lines · python
History for this file →
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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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()