studio: launch batch — product analytics + UX polish + dispatch-test reconcile

Usage tracking: Umami pageviews (analytics.hanzo.ai, pre-existing site id) and
six product events (generate, fix, compose, stack_create, rate, template_use)
via a zero-dependency sendBeacon to insights.hanzo.ai/v1/e — the /v1 prefix is
load-bearing (root /e/ is IAM-gated) and the key is the project's public write
token. Telemetry fails silent.

UX: body clears the floating shell dock (content was hidden under it at page
bottom on phone); stack name input flexes instead of truncating.

Tests: gpu_dispatch tests updated to the has_models_for(prompt) seam
(per-graph model check renamed the old has_local_models).
This commit is contained in:
Hanzo AI
2026-07-17 22:39:37 -07:00
parent d6b888dbd0
commit 5087403496
2 changed files with 18 additions and 12 deletions
+14 -8
View File
@@ -34,7 +34,7 @@ header .user .nm{font-size:.82rem;color:var(--dim)}
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
.btn:hover{border-color:var(--dim)} .btn.brand{background:var(--brand);border-color:var(--brand);color:#111;font-weight:600}
main{max-width:1120px;margin:0 auto;padding:12px 24px 80px}
body{padding-top:56px} /* clear the fixed header */
body{padding-top:56px;padding-bottom:84px} /* clear the fixed header + the shell dock */
body.immersive{padding-top:0}
/* persistent live-queue bar — always visible so you can see what's generating */
#livebar{position:sticky;top:56px;z-index:19;max-width:1120px;margin:0 auto 8px;display:flex;align-items:center;gap:14px;
@@ -228,7 +228,7 @@ body.immersive #stage{display:block}
#svbulk[hidden]{display:none} /* .row's display:flex would otherwise beat [hidden] */
.sv-head{display:flex;align-items:center;gap:12px;margin:8px 0 6px;flex-wrap:wrap}
.sv-back{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 12px;font-size:.85rem;cursor:pointer}
.sv-name{font-size:1.5rem;font-weight:600;background:none;border:none;border-bottom:1px solid transparent;color:var(--txt);font-family:inherit;padding:2px 2px;min-width:120px;max-width:60vw}
.sv-name{font-size:1.5rem;font-weight:600;background:none;border:none;border-bottom:1px solid transparent;color:var(--txt);font-family:inherit;padding:2px 2px;min-width:120px;max-width:60vw;flex:1 1 auto}
.sv-name:hover,.sv-name:focus{border-bottom-color:var(--line);outline:none}
.sv-count{color:var(--dim);font-size:.82rem}
.sv-desc{width:100%;max-width:820px;background:none;border:none;color:var(--dim);font:inherit;font-size:.85rem;resize:none;margin:2px 0 10px;outline:none}
@@ -451,6 +451,7 @@ body.immersive #stage{display:block}
<div class="row" style="justify-content:flex-end;gap:6px;margin-top:12px"><button class="mini" onclick="fbdlg.close()">Cancel</button><button class="mini hot" onclick="saveFeedback()">Save feedback</button></div>
</dialog>
<div id="live" class="sr-only" aria-live="polite" role="status"></div>
<script defer src="https://analytics.hanzo.ai/script.js" data-website-id="2a8e455c-2b24-481b-860b-2499911ce2c2"></script>
<script>
// ── Template / KIND catalog — everything Studio supports ─────────────────────
const TEMPLATES=[
@@ -511,7 +512,12 @@ async function renderTemplate(slug,name){
toast(r.ok?'Render queued → see Queue & History':'Failed: '+(errMsg(j)||('error '+r.status)));
}
function editorCtx(){ if(CTX&&CTX.id)location.href='/?advanced=1&load='+encodeURIComponent(CTX.id); }
function pickTemplate(k,t){$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t);}
// ── Product usage → insights.hanzo.ai. sendBeacon to the capture endpoint; the
// /v1 prefix is load-bearing (root /e/ is IAM-gated). The key is the project's
// public write token. Fails silent — telemetry must never break the product.
const DID=localStorage.hz_did||(localStorage.hz_did=(crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)));
function track(ev,props){try{navigator.sendBeacon('https://insights.hanzo.ai/v1/e',JSON.stringify({api_key:'hi_nq2pI13AO3Yj5mN15uWAtK1ZvubclMpq08Ca9kn3eUO',event:ev,properties:{distinct_id:DID,product:'studio',org:(window.DATA&&DATA.org)||'',...(props||{})}}))}catch(_){}}
function pickTemplate(k,t){$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t); track('template_use',{template:t});}
function pickPreset(){toast('Design systems / presets — coming in the preset library');}
// The ONE create path: the top composer's ↑ generates from its prompt + the photos
// attached to it (via + / drag / paste / a gallery card's +). 1 photo → edit (fix),
@@ -748,7 +754,7 @@ async function sendCompose(){
const stack=await resolveDest($('compdest'));
const r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths,instruction:t,stack})});
const j=await r.json().catch(()=>({}));
if(r.ok){ $('compdlg').close(); toast(`Compose queued (${j.refs} refs) → see Queue & History`); clearSel(); }
if(r.ok){ $('compdlg').close(); toast(`Compose queued (${j.refs} refs) → see Queue & History`); track('compose',{refs:j.refs}); clearSel(); }
else if(!(await healStale(j))){ $('gpuwarn2').style.display='block'; $('gpuwarn2').textContent='Couldnt queue: '+(errMsg(j)||('error '+r.status)); }
}
function pipes(){ $('wfgrid').innerHTML=(DATA.workflows||[]).map(w=>`<div class="card"><div class="doc">⚙︎ ${w}</div><div class="meta"><div class="row"><a class="mini" href="/?advanced=1&workflow=${encodeURIComponent(w)}">Edit</a><a class="mini hot" href="/?advanced=1&workflow=${encodeURIComponent(w)}&run=1">Run</a></div></div></div>`).join('')||'<div class="empty">No saved workflows for this org yet.</div>'; }
@@ -929,7 +935,7 @@ async function castVote(path,v){
const cur=RATINGS[path]||{}, nv=cur.vote===v?0:v; // click the same thumb again = clear
RATINGS[path]={...cur,vote:nv}; render();
try{await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:path,path,vote:nv})});}catch(_){}
toast(nv===1?'👍 Thanks — added to the training set':nv===-1?'👎 Noted — add a 💬 note so the model learns why':'Vote cleared');
toast(nv===1?'👍 Thanks — added to the training set':nv===-1?'👎 Noted — add a 💬 note so the model learns why':'Vote cleared'); track('rate',{score:nv});
refreshFbCount();
}
let FBPATH=null,FBVOTE=0;
@@ -940,7 +946,7 @@ async function saveFeedback(){
if(!FBPATH)return; const text=($('fbtext').value||'').slice(0,2000);
RATINGS[FBPATH]={...(RATINGS[FBPATH]||{}),vote:FBVOTE,notes:text}; $('fbdlg').close(); render();
try{await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:FBPATH,path:FBPATH,vote:FBVOTE,notes:text})});}catch(_){}
toast('Feedback saved — it trains the model'); refreshFbCount();
toast('Feedback saved — it trains the model'); track('rate',{score:FBVOTE,noted:!!text}); refreshFbCount();
}
async function refreshFbCount(){try{const s=await (await fetch('/v1/feedback/stats')).json();const b=$('fbexport');if(b)b.textContent='⬇ Feedback'+(s.total?' ('+s.total+')':'');}catch(_){}}
function exportFeedback(){window.open('/v1/feedback/export','_blank');}
@@ -1106,7 +1112,7 @@ async function sendFix(){const t=$('fixtext').value.trim();if(t.length<3){showFi
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[FIXPATH,...paths],instruction:t,uploads,stack})});
}
const j=await r.json().catch(()=>({}));
if(r.ok){ $('fixdlg').close(); toast(j.note || (FIXMODE==='amend'?'Amended — re-queued (position reset)':'Fix queued → see Queue & History')); if(typeof runs==='function')runs(); }
if(r.ok){ $('fixdlg').close(); toast(j.note || (FIXMODE==='amend'?'Amended — re-queued (position reset)':'Fix queued → see Queue & History')); track('fix',{mode:FIXMODE}); if(typeof runs==='function')runs(); }
else showFixErr('Couldnt '+(FIXMODE==='amend'?'amend':'queue')+': '+(errMsg(j)||('error '+r.status))); // stay open so the reason is visible
}
async function cancelJob(id){
@@ -1249,7 +1255,7 @@ async function dropOnStack(e,id){e.preventDefault();e.currentTarget.classList.re
async function createStack(name,ids){
const r=await fetch('/v1/stacks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,assetIds:ids})});
if(!r.ok){toast('Couldnt create Stack');return null;}
const s=await r.json();await load();toast('Stack created: '+(s.name||'Untitled Stack'));announce('Stack created');return s;
const s=await r.json();await load();toast('Stack created: '+(s.name||'Untitled Stack'));announce('Stack created');track('stack_create',{n:(ids||[]).length});return s;
}
async function createStackFromDrag(a,b){const name=await ask('Name this Stack','Untitled Stack');if(name===null)return;await createStack(name||'Untitled Stack',[a,b]);}
async function newStackPrompt(){const name=await ask('Name this Stack','Untitled Stack');if(name===null)return;const s=await createStack(name||'Untitled Stack',[]);if(s)openStack(s.id);}
@@ -68,7 +68,7 @@ def test_modelless_pod_enqueues_even_when_org_sees_no_gpu(monkeypatch):
sibling org), but a MODEL-LESS pod must still enqueue to gpu-jobs and wait never
self-POST the GPU-less pod (the instant 'failed validation')."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: []) # org-blind
monkeypatch.setattr(g, "has_local_models", lambda: False) # cloud coordinator
monkeypatch.setattr(g, "has_models_for", lambda prompt: False) # cloud coordinator
sent = _capture_enqueue(monkeypatch)
ok = g.dispatch_if_worker(_Req("Bearer karma"), "karma", "pid1", {})
assert ok is True # enqueued to the worker lane, NOT local
@@ -81,7 +81,7 @@ def test_modelless_pod_never_returns_local(monkeypatch):
"""Even if the enqueue POST fails, a model-less pod returns False so server.py
yields a retryable 503 it must NEVER fall through to local validation."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
monkeypatch.setattr(g, "has_local_models", lambda: False)
monkeypatch.setattr(g, "has_models_for", lambda prompt: False)
monkeypatch.setattr(g, "_collect_input_images", lambda prompt, org: [])
def boom(req, timeout=0):
@@ -97,7 +97,7 @@ def test_box_with_models_renders_local_when_no_worker(monkeypatch):
"""A box that HAS its own models (local dev / worker node) still renders locally
when it sees no online worker unchanged behavior."""
monkeypatch.setattr(g, "_online_gpu_nodes", lambda tok: [])
monkeypatch.setattr(g, "has_local_models", lambda: True)
monkeypatch.setattr(g, "has_models_for", lambda prompt: True)
touched = {"urlopen": False}
monkeypatch.setattr(g.urllib.request, "urlopen",
lambda *a, **k: touched.update(urlopen=True) or _Resp())
@@ -108,7 +108,7 @@ def test_box_with_models_renders_local_when_no_worker(monkeypatch):
def test_online_worker_enqueues_with_its_label(monkeypatch):
monkeypatch.setattr(g, "_online_gpu_nodes",
lambda tok: [{"name": "spark", "status": "online", "gpu": True}])
monkeypatch.setattr(g, "has_local_models", lambda: True) # an online worker wins
monkeypatch.setattr(g, "has_models_for", lambda prompt: True) # an online worker wins
sent = _capture_enqueue(monkeypatch)
assert g.dispatch_if_worker(_Req(), "acme", "pid", {}) is True
assert sent["node"] == "spark"