← Commit history

dashboard: ralph-test fixes - instant skeleton so the page never sits empty, first paint no longer blocks on /api/targets, unresponsive boxes report unknown instead of confident falsehoods, no raw exceptions in the UI, surfaces auto-launch KiCad (automation doctrine) and render from state.shots, one state read so the plugin LED cannot contradict the window list

John Lauer ·27d1a81902 ·1mo ago ·parent abb9f66
2 files changed +133−23
dashboard/kicad_dashboard.py+92−19
@@ -80,8 +80,12 @@ def ab(target, verb, args=None, timeout=140):             except Exception:                 pass         return d+    except subprocess.TimeoutExpired:+        return {"error": f"no answer within {timeout}s", "timedOut": True}     except Exception as e:-        return {"error": str(e)}+        # never leak a raw Python exception (a TimeoutExpired repr used to land+        # verbatim in the Bridge LED as "Command '['adom-bridge', ...]'")+        return {"error": str(e).split("\n")[0][:120]}   def get_targets():@@ -202,9 +206,31 @@ def build_status(target):                 "_hint": "Every LED carries its own _hint naming the verb that fixes it. "                          "POST /api/action {action,args} to act; subscribe to /events for live pushes."} -    r = ab(target, "kicad_readiness", {})+    T = 25  # per-facet ceiling: a box that cannot answer in 25s is "not responding"+    r = ab(target, "kicad_readiness", {}, timeout=T)+    if r.get("error"):+        # THE TRUTHFULNESS RULE (ralph iter-3): if the box did not answer, we do+        # not KNOW anything downstream. Saying "KiCad not installed" or "up to+        # date" here would be a confident lie built on a failed call.+        leds["box"] = {"label": "Box", "state": "err",+                       "detail": f"{target} not responding",+                       "_hint": "The box is on the relay but its bridge did not answer in time. "+                                "It may be asleep, mid-upgrade, or the desktop app is wedged - "+                                "pick another box with the pills, or retry with Refresh."}+        for k, lbl in (("bridge", "Bridge"), ("kicad", "KiCad"), ("update", "Updates"),+                       ("plugin", "Plugin"), ("focus", "Focus contract"),+                       ("presence", "User presence")):+            leds[k] = {"label": lbl, "state": "off", "detail": "unknown",+                       "_hint": f"Unknowable while {target} is not answering ({r.get('error')})."}+        if DEV_MODE:+            leds["suite"] = _suite_led()+        return {"target": target, "targets": targets, "leds": leds, "settings": {},+                "windows": [], "dashboardVersion": VERSION,+                "generatedAt": time.strftime("%H:%M:%S"),+                "_hint": "The selected box did not answer; every dependent facet reads unknown "+                         "rather than guessing. Switch boxes or retry."}     installed = bool(r.get("installed", r.get("defaultVersion")))-    cu = ab(target, "kicad_check_for_updates", {})+    cu = ab(target, "kicad_check_for_updates", {}, timeout=T)     ver = cu.get("currentVersion") or r.get("defaultVersion") or "?"     leds["bridge"] = {"label": "Bridge", "state": "ok" if not r.get("error") else "err",                       "detail": "answering" if not r.get("error") else str(r.get("error"))[:70],@@ -224,12 +250,16 @@ def build_status(target):                           "detail": f"up to date (latest {cu.get('latestVersion') or '?'})",                           "_hint": "The bridge checks upstream every 24h and nudges the AI when a release lands."} -    bs = ab(target, "kicad_bridge_status", {})+    bs = ab(target, "kicad_bridge_status", {}, timeout=T)     summary = bs.get("summary") or {}     alive = summary.get("alive") or bs.get("alive") or 0   # alive lives under summary     registered = summary.get("total") or len(bs.get("plugins") or [])-    kicad_open = bool((ab(target, "kicad_state", {"includeImages": False})-                       .get("kicadState") or {}).get("windows"))+    # ONE state read for the whole payload: the plugin LED used to make its own+    # call, so it could say "KiCad closed" while the windows line right below it+    # listed an open window (ralph iter-7).+    st = ab(target, "kicad_state", {"includeImages": False}, timeout=T)+    ks = st.get("kicadState") or st+    kicad_open = bool(ks.get("windows"))     if alive:         leds["plugin"] = {"label": "Plugin", "state": "ok",                           "detail": f"{alive} live instance(s)",@@ -248,8 +278,6 @@ def build_status(target):                                    "usually binds it. Everything else works without it - only "                                    "background part navigation is degraded."} -    st = ab(target, "kicad_state", {"includeImages": False})-    ks = st.get("kicadState") or st     fd = ks.get("focusDebug") or {}     steals = [e for e in (ks.get("focusEvents") or [])[-20:] if "bounced" in str(e.get("action"))]     leds["focus"] = {"label": "Focus contract", "state": "ok" if not steals else "warn",@@ -273,7 +301,7 @@ def build_status(target):     if DEV_MODE:         leds["suite"] = _suite_led() -    settings = (ab(target, "kicad_get_settings", {}, timeout=60) or {}).get("settings") or {}+    settings = (ab(target, "kicad_get_settings", {}, timeout=T) or {}).get("settings") or {}     return {"target": target, "targets": targets, "leds": leds, "settings": settings,             "windows": [w.get("title") for w in (ks.get("windows") or [])],             "bridgeVersion": (ks.get("bridgeVersion") or st.get("bridgeVersion")@@ -300,14 +328,25 @@ def cached_status(target, force=False):     return data  +_targets_cache = {"t": 0.0, "rows": None}++ def probe_targets():+    """Names first, detail second. This endpoint gates the page's first paint,+    so it must never wait on a wedged box: a 10s per-box ceiling, results cached+    for 30s, and a box that does not answer still appears (just without detail)."""+    if _targets_cache["rows"] and time.time() - _targets_cache["t"] < 30:+        return {"targets": _targets_cache["rows"], "cached": True,+                "_hint": "Cached for 30s; the page paints from names alone."}     rows = []     for name in get_targets():-        st = ab(name, "kicad_state", {"includeImages": False}, timeout=60)+        st = ab(name, "kicad_state", {"includeImages": False}, timeout=10)         ks = st.get("kicadState") or st         wins = ks.get("windows") or []-        rows.append({"name": name, "kicadRunning": bool(wins),-                     "windows": len(wins), "kicadVersion": ks.get("kicadVersion")})+        rows.append({"name": name, "kicadRunning": bool(wins), "windows": len(wins),+                     "kicadVersion": ks.get("kicadVersion"),+                     "responding": not bool(st.get("error"))})+    _targets_cache.update(t=time.time(), rows=rows)     return {"targets": rows,             "_hint": "Pick where KiCad should run. The bridge only drives instances IT launched, "                      "so a KiCad the user opened on any box is never parked, bounced, or badged."}@@ -512,6 +551,24 @@ def _surface_args(key, staged_project):     }[key]  +def ensure_ready(target, surface):+    """A click is a GOAL, not a request for an audit (the Adom automation+    doctrine). If KiCad is not running, launch it and wait - never hand the user+    an error they would fix by pressing a button we could have pressed."""+    st = ab(target, "kicad_state", {"includeImages": False}, timeout=30)+    if (st.get("kicadState") or st).get("windows"):+        return True+    _emit(surface, log="KiCad is closed - launching it in the background first", percent=15)+    ab(target, "kicad_launch", {}, timeout=120)+    for _ in range(20):                      # up to ~60s for the first window+        time.sleep(3)+        st = ab(target, "kicad_state", {"includeImages": False}, timeout=30)+        if (st.get("kicadState") or st).get("windows"):+            _emit(surface, log="KiCad is up", percent=25)+            return True+    return False++ def run_surface(target, key):     """Stage what this surface needs, open it in the BACKGROUND, then render it     from kicad_state. Streams progress over SSE and onto the taskbar button."""@@ -527,6 +584,9 @@ def run_surface(target, key):     try:         _emit(key, status="running", log=note, percent=5)         hwnd = taskbar_progress(target, None, "indeterminate")+        if not ensure_ready(target, key):+            raise RuntimeError("KiCad did not come up within 60s - try Launch KiCad, "+                               "or check the KiCad LED for an install problem")         project = None         if need == "part":             stage_part(target, key)@@ -541,8 +601,14 @@ def run_surface(target, key):         _emit(key, log="rendering it from kicad_state", percent=92)         shot, title = _render(target, pick)         taskbar_progress(target, 100, "none", hwnd)+        if not shot:+            # ralph iter-4: the job used to report success with no picture at+            # all, which is the one thing this card exists to show.+            _emit(key, status="error", percent=100,+                  log=f"{label} opened, but no window could be captured to render it")+            return         _emit(key, status="done", percent=100, image=shot, title=title,-              log=f"{label} shown in the background" + (f" ({title})" if title else ""))+              log=f"{label} shown in the background" + (f" - {title}" if title else ""))     except Exception as e:         if hwnd:             taskbar_progress(target, 100, "error", hwnd)@@ -550,16 +616,23 @@ def run_surface(target, key):   def _render(target, pick):-    """Pull this surface's window out of kicad_state as a base64 PNG."""-    st = ab(target, "kicad_state", {"maxWidth": 900}, timeout=240)-    wins = (st.get("kicadState") or st).get("windows") or []+    """Pull this surface's window out of kicad_state as a base64 PNG.++    ralph iter-5: the captures live in state.SHOTS (each {title, image, hwnd,+    path}), NOT in state.windows - windows carries geometry only, so the render+    always came back empty and the card had nothing to show. `includeImages` is+    what produces shots at all."""+    st = ab(target, "kicad_state", {"includeImages": True, "maxWidth": 900}, timeout=240)+    ks = st.get("kicadState") or st+    shots = ks.get("shots") or []     chosen = None     if pick:-        chosen = next((w for w in wins if pick in (w.get("title") or "").lower()), None)-    chosen = chosen or (wins[-1] if wins else None)+        chosen = next((w for w in shots if pick in (w.get("title") or "").lower()), None)+    chosen = chosen or (shots[-1] if shots else None)     if not chosen:         return None, None-    img = chosen.get("image") or chosen.get("screenshot")+    img = (chosen.get("image") or chosen.get("screenshot")+           or chosen.get("imageBase64") or chosen.get("png"))     return img, chosen.get("title")  
dashboard/ui/index.html+41−4
@@ -88,6 +88,8 @@ dialog::backdrop{background:rgba(0,0,0,.55)} #appinfo{position:fixed;z-index:99998;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4);display:none;pointer-events:auto;min-width:220px} #appinfo.show{display:block} #appinfo a{color:var(--accent)}+main a{color:var(--accent);text-decoration:none;border-bottom:1px solid rgba(0,184,177,.35)}+main a:hover{border-bottom-color:var(--accent)} #targets{display:flex;gap:8px;margin:0 0 14px;flex-wrap:wrap} .tpill{display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);cursor:pointer;font:500 13.5px 'Satoshi',sans-serif;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease} .tpill:hover{border-color:#4b5563;background:var(--elevated);color:var(--text)}@@ -196,10 +198,17 @@ async function loadTargets(){     targetsCache=d.targets||[];     const el=$('#targets');el.innerHTML='';     $('#boxnote').style.display=targetsCache.length>1?'':'none';-    if(!curTarget&&targetsCache.length){curTarget=(targetsCache.find(t=>t.kicadRunning)||targetsCache[0]).name;localStorage.setItem('kdTarget',curTarget);}+    const known=targetsCache.map(t=>t.name);+    if(targetsCache.length&&(!curTarget||!known.includes(curTarget))){+      if(curTarget)toast(curTarget+' is no longer connected - switching to '+known[0]);+      curTarget=(targetsCache.find(t=>t.kicadRunning)||targetsCache[0]).name;+      localStorage.setItem('kdTarget',curTarget);+    }     for(const t of targetsCache){       const b=document.createElement('button');b.className='tpill'+(t.name===curTarget?' active':'');-      b.setAttribute('data-tip',(t.kicadRunning?'KiCad is running here':'KiCad is closed here')+(t.kicadVersion?(' - KiCad '+t.kicadVersion):''));+      const resp=(t.responding===false)?'not responding':(t.kicadRunning?'KiCad is running here':'KiCad is closed here');+      b.setAttribute('data-tip',resp+(t.kicadVersion?(' - KiCad '+t.kicadVersion):''));+      if(t.responding===false)b.style.opacity='.55';       b.innerHTML='<span class="tdot'+(t.kicadRunning?' on':'')+'"></span>'+t.name;       b.onclick=()=>{curTarget=t.name;localStorage.setItem('kdTarget',curTarget);loadTargets();refresh();toast('switched to '+t.name)};       el.appendChild(b);@@ -224,7 +233,29 @@ function renderLeds(st){   const w=st.windows||[];   $('#wins').textContent=w.length?('open KiCad windows: '+w.join(' ยท ')):'no KiCad windows open'; }-async function refresh(force){try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);}catch(e){toast('status failed: '+e,'err');}}+const LED_SKELETON=['relay','box','bridge','kicad','update','plugin','focus','presence'];+function renderSkeleton(){+  const g=$('#ledGrid');if(g.children.length)return;   // only before the first real paint+  g.innerHTML=LED_SKELETON.map(k=>`<div class="card"><div class="led-row"><span class="led dim"></span>`++    `<span class="led-label" style="text-transform:capitalize">${k}</span></div>`++    `<div class="led-detail">checking...</div></div>`).join('');+  $('#subject').textContent='checking '+(curTarget||'the default box')+'...';+}+let refreshing=false;+async function refresh(force){+  if(refreshing)return;refreshing=true;renderSkeleton();+  try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);}+  catch(e){+    $('#subject').textContent='status unavailable';+    const g=$('#ledGrid');+    g.innerHTML='<div class="card"><div class="led-row"><span class="led off"></span>'++      '<span class="led-label">Status unavailable</span></div>'++      '<div class="led-detail">'+String(e).slice(0,80)+'</div>'++      '<div class="led-sub">The dashboard could not read this box. Pick another with the pills above, or press Refresh.</div></div>';+    toast('status failed: '+e,'err');+  }+  finally{refreshing=false;}+} $('#btnRefresh').onclick=()=>{toast('refreshing status');refresh(true);}; $('#btnLaunch').onclick=async()=>{toast('launching KiCad in the background');await act('launch');setTimeout(()=>refresh(true),4000);}; $('#btnClose').onclick=async()=>{toast('closing the bridge\u2019s KiCad windows');await act('close');setTimeout(()=>refresh(true),4000);};@@ -402,8 +433,14 @@ brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout( ai.addEventListener('mouseenter',()=>clearTimeout(aiHide)); ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);}); +// FIRST PAINT MUST NOT WAIT ON THE NETWORK (ralph iter-2: /api/targets probes+// every box, so one wedged laptop left the whole page on "connecting..." with+// no LEDs and no pills). Skeleton now, then status and targets INDEPENDENTLY.+renderSkeleton(); buildSurfaces();-loadTargets().then(()=>refresh());setInterval(()=>refresh(),15000);setInterval(loadTargets,60000);+refresh();+loadTargets();+setInterval(()=>refresh(),15000);setInterval(loadTargets,60000); </script> </body> </html>