FlashTriage / frontend /index.html
Chris4K's picture
Upload 19 files
39a5a79 verified
Raw
History Blame Contribute Delete
22.4 kB
<!doctype html>
<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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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>