Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>FlashTriage — scanner findings in, triaged in seconds</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --bg:#0A0E16; --panel:#111726; --panel2:#0D1320; --line:#1E2940; | |
| --text:#E8EDF7; --muted:#7E8AA3; --faint:#475068; | |
| --cyan:#2BE0FF; --amber:#FFB020; --baseline:#6B7793; | |
| --crit:#FF4D5E; --high:#FF8A47; --med:#FFC53D; --low:#4FD17A; --info:#5AA9FF; | |
| --disp:'Space Grotesk',system-ui,sans-serif; | |
| --body:'Inter',system-ui,sans-serif; | |
| --mono:'JetBrains Mono',ui-monospace,monospace; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{margin:0} | |
| body{background: | |
| radial-gradient(900px 500px at 85% -10%, rgba(43,224,255,.06), transparent 60%), | |
| radial-gradient(700px 400px at -5% 110%, rgba(255,176,32,.05), transparent 55%), | |
| var(--bg); | |
| color:var(--text); font-family:var(--body); -webkit-font-smoothing:antialiased; | |
| min-height:100vh;} | |
| .wrap{max-width:1180px;margin:0 auto;padding:22px 22px 80px} | |
| /* ---- top bar ---- */ | |
| header.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap; | |
| padding-bottom:18px;border-bottom:1px solid var(--line)} | |
| .logo{font-family:var(--disp);font-weight:700;font-size:22px;letter-spacing:-.02em} | |
| .logo b{color:var(--cyan)} | |
| .logo .flash{display:inline-block} | |
| .tag{color:var(--muted);font-size:13.5px} | |
| .spacer{flex:1} | |
| .badge{font-family:var(--mono);font-size:11.5px;color:var(--text); | |
| border:1px solid var(--line);background:var(--panel2); | |
| padding:6px 10px;border-radius:999px;display:flex;gap:8px;align-items:center} | |
| .dot{width:7px;height:7px;border-radius:50%;background:var(--cyan); | |
| box-shadow:0 0 0 0 rgba(43,224,255,.6);animation:pulse 2s infinite} | |
| @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(43,224,255,.5)}70%{box-shadow:0 0 0 7px rgba(43,224,255,0)}100%{box-shadow:0 0 0 0 rgba(43,224,255,0)}} | |
| /* ---- controls ---- */ | |
| .controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:18px 0 6px} | |
| .src{display:flex;gap:6px;background:var(--panel2);border:1px solid var(--line); | |
| border-radius:10px;padding:4px} | |
| .src button{font-family:var(--body);font-size:13px;color:var(--muted);background:none; | |
| border:0;padding:7px 12px;border-radius:7px;cursor:pointer} | |
| .src button.on{background:var(--panel);color:var(--text)} | |
| .slide{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12.5px;font-family:var(--mono)} | |
| input[type=range]{accent-color:var(--cyan);width:120px} | |
| .btn{font-family:var(--disp);font-weight:600;font-size:14px;border:0;border-radius:10px; | |
| padding:11px 18px;cursor:pointer;letter-spacing:-.01em} | |
| .btn.run{background:linear-gradient(180deg,var(--cyan),#15b9d6);color:#04222b; | |
| box-shadow:0 6px 22px -8px rgba(43,224,255,.7)} | |
| .btn.run:hover{filter:brightness(1.06)} | |
| .btn.ghost{background:var(--panel);color:var(--text);border:1px solid var(--line)} | |
| .btn:disabled{opacity:.45;cursor:not-allowed} | |
| textarea#paste{display:none;width:100%;height:120px;margin-top:10px;background:var(--panel2); | |
| color:var(--text);border:1px solid var(--line);border-radius:10px;padding:12px; | |
| font-family:var(--mono);font-size:12px;resize:vertical} | |
| /* ---- race ---- */ | |
| .race{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:18px} | |
| @media(max-width:760px){.race{grid-template-columns:1fr}} | |
| .lane{background:var(--panel);border:1px solid var(--line);border-radius:14px; | |
| padding:14px 14px 12px;min-height:230px;position:relative;overflow:hidden} | |
| .lane.cb{border-color:rgba(43,224,255,.35)} | |
| .lane h3{margin:0;font-family:var(--disp);font-size:14px;display:flex;align-items:center;gap:8px} | |
| .lane .pill{font-family:var(--mono);font-size:10px;padding:2px 7px;border-radius:999px; | |
| border:1px solid var(--line);color:var(--muted)} | |
| .lane.cb .pill{color:var(--cyan);border-color:rgba(43,224,255,.4)} | |
| .clock{font-family:var(--mono);font-size:30px;font-weight:700;letter-spacing:-.02em;margin:8px 0 2px} | |
| .lane.cb .clock{color:var(--cyan)} | |
| .lane.bl .clock{color:var(--baseline)} | |
| .sub{font-family:var(--mono);font-size:11px;color:var(--muted);margin-bottom:10px} | |
| .track{height:5px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-bottom:11px} | |
| .track > i{display:block;height:100%;width:0;border-radius:3px;transition:width .25s ease} | |
| .lane.cb .track > i{background:var(--cyan)} | |
| .lane.bl .track > i{background:var(--baseline)} | |
| .chips{display:flex;flex-wrap:wrap;gap:6px;align-content:flex-start} | |
| .chip{font-family:var(--mono);font-size:10.5px;padding:3px 8px;border-radius:6px; | |
| border:1px solid var(--line);color:var(--text);background:var(--panel2); | |
| animation:pop .28s ease both} | |
| @keyframes pop{from{opacity:0;transform:translateY(6px) scale(.96)}to{opacity:1;transform:none}} | |
| .chip.crit{border-color:var(--crit);color:var(--crit)} | |
| .chip.high{border-color:var(--high);color:var(--high)} | |
| .chip.med{border-color:var(--med);color:var(--med)} | |
| .chip.low{border-color:var(--low);color:var(--low)} | |
| .chip.info{border-color:var(--info);color:var(--info)} | |
| /* ---- verdict ---- */ | |
| .verdict{margin-top:14px;background:linear-gradient(90deg,rgba(43,224,255,.10),transparent); | |
| border:1px solid rgba(43,224,255,.3);border-radius:14px;padding:16px 18px; | |
| display:none;align-items:baseline;gap:14px;flex-wrap:wrap} | |
| .verdict.show{display:flex;animation:pop .35s ease both} | |
| .verdict .x{font-family:var(--disp);font-weight:700;font-size:34px;color:var(--cyan);letter-spacing:-.03em} | |
| .verdict .vtxt{color:var(--text);font-size:14px} | |
| .verdict .vtxt b{font-family:var(--mono)} | |
| /* ---- commander ---- */ | |
| .cmd{display:none;margin-top:18px;background:var(--panel);border:1px solid var(--line); | |
| border-radius:14px;padding:18px} | |
| .cmd.show{display:block} | |
| .cmd .eye{font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;color:var(--amber);text-transform:uppercase} | |
| .cmd h2{font-family:var(--disp);font-size:19px;margin:6px 0 8px;letter-spacing:-.01em} | |
| .cmd p{color:var(--muted);font-size:14px;line-height:1.55;margin:0 0 12px;max-width:70ch} | |
| .counts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px} | |
| .cnt{font-family:var(--mono);font-size:12px;padding:5px 10px;border-radius:8px;border:1px solid var(--line)} | |
| .cnt b{font-weight:700} | |
| .actions{list-style:none;margin:0;padding:0;display:grid;gap:7px} | |
| .actions li{display:flex;gap:10px;align-items:baseline;font-size:13.5px} | |
| .actions li::before{content:"→";color:var(--cyan)} | |
| /* ---- results board ---- */ | |
| .board{margin-top:22px;display:none} | |
| .board.show{display:block} | |
| .board h2{font-family:var(--disp);font-size:15px;margin:0 0 4px} | |
| .board .hint{color:var(--faint);font-size:12px;margin-bottom:12px} | |
| .cards{display:grid;gap:9px} | |
| .card{background:var(--panel);border:1px solid var(--line);border-left-width:3px; | |
| border-radius:11px;padding:13px 14px;cursor:pointer;transition:border-color .15s, transform .1s} | |
| .card:hover{transform:translateX(2px)} | |
| .card.crit{border-left-color:var(--crit)} .card.high{border-left-color:var(--high)} | |
| .card.med{border-left-color:var(--med)} .card.low{border-left-color:var(--low)} | |
| .card.info{border-left-color:var(--info)} | |
| .crow{display:flex;align-items:center;gap:10px;flex-wrap:wrap} | |
| .cvss{font-family:var(--mono);font-weight:700;font-size:15px;min-width:38px} | |
| .crit .cvss{color:var(--crit)} .high .cvss{color:var(--high)} .med .cvss{color:var(--med)} | |
| .low .cvss{color:var(--low)} .info .cvss{color:var(--info)} | |
| .cat{font-family:var(--disp);font-weight:600;font-size:14px} | |
| .meta{font-family:var(--mono);font-size:10.5px;color:var(--muted)} | |
| .exploit{font-family:var(--mono);font-size:10px;padding:2px 7px;border-radius:5px;border:1px solid var(--line);color:var(--muted)} | |
| .exploit.trivial{color:var(--crit);border-color:var(--crit)} | |
| .exploit.plausible{color:var(--high);border-color:var(--high)} | |
| .rationale{color:var(--muted);font-size:12.5px;margin-top:7px;line-height:1.5} | |
| .fix{margin-top:9px;font-size:12.5px;color:var(--text);background:var(--panel2); | |
| border:1px solid var(--line);border-radius:8px;padding:9px 11px} | |
| .fix code{font-family:var(--mono);color:var(--cyan);font-size:11.5px} | |
| .fix .lbl{font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;color:var(--low);text-transform:uppercase;display:block;margin-bottom:4px} | |
| /* ---- deep dive drawer ---- */ | |
| .drawer{position:fixed;top:0;right:0;height:100%;width:min(440px,92vw); | |
| background:var(--panel2);border-left:1px solid var(--line); | |
| transform:translateX(100%);transition:transform .25s ease;z-index:40; | |
| padding:22px;overflow:auto} | |
| .drawer.open{transform:none} | |
| .drawer .close{position:absolute;top:14px;right:16px;background:none;border:0;color:var(--muted);font-size:22px;cursor:pointer} | |
| .drawer .eye{font-family:var(--mono);font-size:10px;letter-spacing:.14em;color:var(--cyan);text-transform:uppercase} | |
| .drawer h3{font-family:var(--disp);font-size:17px;margin:6px 0 14px} | |
| .drop{border:1.5px dashed var(--line);border-radius:12px;padding:26px 16px;text-align:center; | |
| color:var(--muted);font-size:13px;cursor:pointer;transition:border-color .15s,background .15s} | |
| .drop:hover,.drop.over{border-color:var(--cyan);background:rgba(43,224,255,.05)} | |
| .drop img{max-width:100%;border-radius:8px;margin-top:4px} | |
| .rca{margin-top:16px;display:none} | |
| .rca.show{display:block} | |
| .rca .k{font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--amber);text-transform:uppercase;margin-top:14px} | |
| .rca .v{font-size:13.5px;line-height:1.55;margin-top:4px} | |
| .rca code{font-family:var(--mono);color:var(--cyan);font-size:12px} | |
| .spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line); | |
| border-top-color:var(--cyan);border-radius:50%;animation:sp .7s linear infinite;vertical-align:-2px} | |
| @keyframes sp{to{transform:rotate(360deg)}} | |
| .overlay{position:fixed;inset:0;background:rgba(4,7,12,.55);z-index:35;display:none} | |
| .overlay.show{display:block} | |
| @media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <header class="bar"> | |
| <div class="logo"><span class="flash">⚡</span> Flash<b>Triage</b></div> | |
| <div class="tag">Scanner findings in. Triaged in seconds.</div> | |
| <div class="spacer"></div> | |
| <div class="badge"><span class="dot"></span> <span id="modelBadge">gemma-4-31b · Cerebras</span></div> | |
| </header> | |
| <div class="controls"> | |
| <div class="src" id="src"> | |
| <button data-mode="sample" class="on">Load sample</button> | |
| <button data-mode="paste">Paste output</button> | |
| <button data-mode="upload">Upload file</button> | |
| </div> | |
| <div class="slide">batch | |
| <input type="range" id="limit" min="3" max="14" value="8"> | |
| <span id="limitVal">8</span></div> | |
| <button class="btn run" id="runRace">Run race ▶</button> | |
| <button class="btn ghost" id="runCb">Cerebras only</button> | |
| <input type="file" id="file" accept=".json,.sarif,.txt" style="display:none"> | |
| </div> | |
| <textarea id="paste" placeholder="Paste SARIF, JSON, or raw scanner text here…"></textarea> | |
| <section class="race"> | |
| <div class="lane cb" id="laneCb"> | |
| <h3>Cerebras <span class="pill" id="pillCb">gemma-4-31b · parallel</span></h3> | |
| <div class="clock" id="clockCb">0.00s</div> | |
| <div class="sub" id="subCb">idle</div> | |
| <div class="track"><i id="trackCb"></i></div> | |
| <div class="chips" id="chipsCb"></div> | |
| </div> | |
| <div class="lane bl" id="laneBl"> | |
| <h3>Baseline <span class="pill" id="pillBl">sequential</span></h3> | |
| <div class="clock" id="clockBl">0.00s</div> | |
| <div class="sub" id="subBl">idle</div> | |
| <div class="track"><i id="trackBl"></i></div> | |
| <div class="chips" id="chipsBl"></div> | |
| </div> | |
| </section> | |
| <div class="verdict" id="verdict"> | |
| <span class="x" id="vx">—</span> | |
| <span class="vtxt" id="vtxt"></span> | |
| </div> | |
| <section class="cmd" id="cmd"> | |
| <div class="eye">Incident commander · executive rollup</div> | |
| <h2 id="cmdHead"></h2> | |
| <p id="cmdRisk"></p> | |
| <div class="counts" id="cmdCounts"></div> | |
| <ul class="actions" id="cmdActions"></ul> | |
| </section> | |
| <section class="board" id="board"> | |
| <h2>Triaged findings</h2> | |
| <div class="hint">Sorted by real risk, not scanner label. Click any finding for a multimodal deep-dive →</div> | |
| <div class="cards" id="cards"></div> | |
| </section> | |
| </div> | |
| <div class="overlay" id="overlay"></div> | |
| <aside class="drawer" id="drawer"> | |
| <button class="close" id="dClose">×</button> | |
| <div class="eye">Vision root-cause · Gemma-4 multimodal</div> | |
| <h3 id="dTitle">Finding</h3> | |
| <div class="drop" id="drop">Drop a screenshot of the code / dashboard / alert<br><small>or click to choose</small></div> | |
| <input type="file" id="dFile" accept="image/*" style="display:none"> | |
| <div class="rca" id="rca"></div> | |
| </aside> | |
| <script> | |
| const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; | |
| const SEV=['crit','high','med','low','info']; | |
| const labelToClass=l=>({critical:'crit',high:'high',medium:'med',low:'low',info:'info'}[l]||'info'); | |
| let mode='sample', uploaded=null, lastResults=[], blLabel='baseline'; | |
| // ---- source toggle ---- | |
| $$('#src button').forEach(b=>b.onclick=()=>{ | |
| $$('#src button').forEach(x=>x.classList.remove('on')); b.classList.add('on'); | |
| mode=b.dataset.mode; | |
| $('#paste').style.display = mode==='paste'?'block':'none'; | |
| if(mode==='upload') $('#file').click(); | |
| }); | |
| $('#file').onchange=e=>{const f=e.target.files[0]; if(!f)return; | |
| const r=new FileReader(); r.onload=()=>{uploaded=r.result;}; r.readAsText(f);}; | |
| $('#limit').oninput=e=>$('#limitVal').textContent=e.target.value; | |
| async function getPayload(){ | |
| if(mode==='paste') return $('#paste').value; | |
| if(mode==='upload') return uploaded || ''; | |
| const r=await fetch('/api/sample'); return JSON.stringify(await r.json()); | |
| } | |
| // ---- SSE-over-POST reader ---- | |
| async function* sse(url, body){ | |
| const res=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); | |
| const reader=res.body.getReader(), dec=new TextDecoder(); let buf=''; | |
| while(true){ | |
| const {value,done}=await reader.read(); if(done)break; | |
| buf+=dec.decode(value,{stream:true}); let i; | |
| while((i=buf.indexOf('\n\n'))>=0){ | |
| const line=buf.slice(0,i).trim(); buf=buf.slice(i+2); | |
| if(line.startsWith('data:')){ try{ yield JSON.parse(line.slice(5).trim()); }catch{} } | |
| } | |
| } | |
| } | |
| // ---- one lane ---- | |
| function lane(which){ | |
| const cl=which==='cb', n=cl?'Cb':'Bl'; | |
| return { | |
| clock:$('#clock'+n), sub:$('#sub'+n), track:$('#track'+n), chips:$('#chips'+n), | |
| pill:$('#pill'+n), start:0, raf:0, total:0, count:0, done:false, | |
| reset(){this.chips.innerHTML='';this.track.style.width='0';this.clock.textContent='0.00s'; | |
| this.sub.textContent='running…';this.done=false;this.count=0;this.start=performance.now(); | |
| const tick=()=>{ if(this.done)return; this.clock.textContent=((performance.now()-this.start)/1000).toFixed(2)+'s'; | |
| this.raf=requestAnimationFrame(tick);}; tick();}, | |
| chip(d){ const c=document.createElement('span'); | |
| c.className='chip '+labelToClass(d.label); | |
| c.textContent=(d.category||d.rule||'finding')+' '+(d.cvss??''); | |
| this.chips.appendChild(c); this.count++; | |
| this.track.style.width=Math.min(100,(this.count/this.total)*100)+'%'; | |
| this.sub.textContent=this.count+' / '+this.total+' triaged';}, | |
| finish(ms){ this.done=true; cancelAnimationFrame(this.raf); | |
| this.clock.textContent=(ms/1000).toFixed(2)+'s'; this.track.style.width='100%'; | |
| this.sub.textContent=this.count+' findings · '+(ms/1000).toFixed(2)+'s';} | |
| }; | |
| } | |
| async function drive(laneObj, laneName, limit, collect){ | |
| laneObj.reset(); | |
| const payload=await getPayload(); | |
| let summaryMs=0; | |
| for await(const ev of sse('/api/triage',{payload,lane:laneName,limit})){ | |
| if(ev.type==='meta'){ laneObj.total=ev.data.count||limit; | |
| if(laneName==='cerebras'){$('#pillCb').textContent=ev.data.model+' · parallel'; $('#modelBadge').textContent=ev.data.model+' · Cerebras';} | |
| else {blLabel=ev.data.model; $('#pillBl').textContent=ev.data.model+' · sequential';} | |
| } | |
| else if(ev.type==='finding'){ laneObj.chip(ev.data); if(collect) lastResults.push(ev.data); } | |
| else if(ev.type==='rollup'){ if(collect) renderCmd(ev.data); } | |
| else if(ev.type==='summary'){ summaryMs=ev.data.wall_ms; laneObj.finish(summaryMs); | |
| if(collect){ laneObj.tps=ev.data.throughput_tps; } } | |
| } | |
| return summaryMs; | |
| } | |
| // ---- run handlers ---- | |
| async function runRace(){ | |
| setBusy(true); resetOutputs(); | |
| const limit=+$('#limit').value; | |
| const [cbMs,blMs]=await Promise.all([ | |
| drive(lane('cb'),'cerebras',limit,true), | |
| drive(lane('bl'),'baseline',limit,false) | |
| ]); | |
| showVerdict(cbMs,blMs,limit); | |
| renderBoard(); setBusy(false); | |
| } | |
| async function runCb(){ | |
| setBusy(true); resetOutputs(); | |
| const limit=+$('#limit').value; | |
| const cbMs=await drive(lane('cb'),'cerebras',limit,true); | |
| $('#subBl').textContent='—'; renderBoard(); setBusy(false); | |
| } | |
| $('#runRace').onclick=runRace; $('#runCb').onclick=runCb; | |
| function setBusy(b){ $('#runRace').disabled=b; $('#runCb').disabled=b; } | |
| function resetOutputs(){ lastResults=[]; $('#verdict').classList.remove('show'); | |
| $('#cmd').classList.remove('show'); $('#board').classList.remove('show'); $('#cards').innerHTML=''; | |
| $('#chipsBl').innerHTML=''; $('#trackBl').style.width='0'; $('#clockBl').textContent='0.00s'; $('#subBl').textContent='idle';} | |
| function showVerdict(cb,bl,n){ | |
| const x=(bl/Math.max(cb,1)).toFixed(1); | |
| $('#vx').textContent=x+'×'; | |
| $('#vtxt').innerHTML=`faster on Cerebras — <b>${n}</b> findings triaged in <b>${(cb/1000).toFixed(2)}s</b> vs <b>${(bl/1000).toFixed(2)}s</b> on ${esc(blLabel)}. Fast enough to run on every scan.`; | |
| $('#verdict').classList.add('show'); | |
| } | |
| function renderCmd(r){ | |
| if(!r||!r.headline)return; | |
| $('#cmdHead').textContent=r.headline; $('#cmdRisk').textContent=r.risk_paragraph||''; | |
| const cc=$('#cmdCounts'); cc.innerHTML=''; | |
| const map={critical:'crit',high:'high',medium:'med',low:'low',info:'info'}; | |
| Object.entries(r.counts||{}).forEach(([k,v])=>{ | |
| const e=document.createElement('span'); e.className='cnt'; | |
| e.style.color=`var(--${map[k]})`; e.innerHTML=`<b>${v}</b> ${k}`; cc.appendChild(e);}); | |
| const ul=$('#cmdActions'); ul.innerHTML=''; | |
| (r.top_actions||[]).forEach(a=>{const li=document.createElement('li');li.textContent=a;ul.appendChild(li);}); | |
| $('#cmd').classList.add('show'); | |
| } | |
| function renderBoard(){ | |
| const sorted=[...lastResults].sort((a,b)=>(b.cvss||0)-(a.cvss||0)); | |
| const wrap=$('#cards'); wrap.innerHTML=''; | |
| sorted.forEach(d=>{ | |
| const cls=labelToClass(d.label); | |
| const card=document.createElement('div'); card.className='card '+cls; | |
| let fix=''; | |
| if(d.remediation){ fix=`<div class="fix"><span class="lbl">Fix · effort ${d.remediation.effort}</span>${esc(d.remediation.fix_summary)}<br><code>${esc(d.remediation.patch_hint)}</code></div>`; } | |
| card.innerHTML=`<div class="crow"> | |
| <span class="cvss">${(d.cvss??0)}</span> | |
| <span class="cat">${esc(d.category||d.rule)}</span> | |
| <span class="exploit ${d.exploitability}">${d.exploitability}</span> | |
| <span class="meta">${esc(d.scanner)} · ${esc(d.path||d.rule)} · fp ${Math.round((d.fp||0)*100)}%</span> | |
| </div> | |
| <div class="rationale">${esc(d.rationale||'')}</div>${fix}`; | |
| card.onclick=()=>openDrawer(d); | |
| wrap.appendChild(card); | |
| }); | |
| $('#board').classList.add('show'); | |
| } | |
| const esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); | |
| // ---- deep-dive drawer (multimodal) ---- | |
| let current=null; | |
| function openDrawer(d){ current=d; $('#dTitle').textContent=(d.category||d.rule)+' · CVSS '+(d.cvss??''); | |
| $('#rca').classList.remove('show'); $('#rca').innerHTML=''; | |
| $('#drop').innerHTML='Drop a screenshot of the code / dashboard / alert<br><small>or click to choose</small>'; | |
| $('#drawer').classList.add('open'); $('#overlay').classList.add('show');} | |
| function closeDrawer(){ $('#drawer').classList.remove('open'); $('#overlay').classList.remove('show'); } | |
| $('#dClose').onclick=closeDrawer; $('#overlay').onclick=closeDrawer; | |
| $('#drop').onclick=()=>$('#dFile').click(); | |
| ['dragover','dragenter'].forEach(e=>$('#drop').addEventListener(e,ev=>{ev.preventDefault();$('#drop').classList.add('over');})); | |
| ['dragleave','drop'].forEach(e=>$('#drop').addEventListener(e,ev=>{ev.preventDefault();$('#drop').classList.remove('over');})); | |
| $('#drop').addEventListener('drop',ev=>handleImg(ev.dataTransfer.files[0])); | |
| $('#dFile').onchange=e=>handleImg(e.target.files[0]); | |
| async function handleImg(f){ | |
| if(!f)return; | |
| const r=new FileReader(); | |
| r.onload=async()=>{ | |
| const dataUrl=r.result, b64=dataUrl.split(',')[1]; | |
| $('#drop').innerHTML=`<img src="${dataUrl}" alt="evidence">`; | |
| $('#rca').classList.add('show'); $('#rca').innerHTML='<div class="k">analyzing</div><div class="v"><span class="spin"></span> Gemma-4 vision is reading the image…</div>'; | |
| try{ | |
| const res=await fetch('/api/deepdive',{method:'POST',headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({context:`Finding: ${current.category} (${current.message||current.rule}). ${current.rationale||''}`, | |
| image_base64:b64, media_type:f.type||'image/png'})}); | |
| const j=await res.json(), x=j.rca||{}; | |
| $('#rca').innerHTML= | |
| `<div class="k">root cause</div><div class="v">${esc(x.root_cause)}</div> | |
| <div class="k">evidence in image</div><div class="v">${esc(x.evidence_from_image)}</div> | |
| <div class="k">patch</div><div class="v"><code>${esc(x.concrete_patch)}</code></div> | |
| <div class="k">confidence · latency</div><div class="v">${Math.round((x.confidence||0)*100)}% · ${(j.wall_ms||0).toFixed(0)}ms</div>`; | |
| }catch(e){ $('#rca').innerHTML='<div class="v">Vision call failed — check the API key / network.</div>'; } | |
| }; | |
| r.readAsDataURL(f); | |
| } | |
| </script> | |
| </body> | |
| </html> | |