app
Adom USB
Public Unreviewedby John Lauer
See every USB device across the entire Adom ecosystem — laptop, WSL2, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>USB Devices — Adom</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml"/>
<style>
@import url("https://adom.inc/fonts/fonts.css");
:root{
--bg:#0d1117; --surface:#161b22; --surface2:#1c2330; --border:#2a3038;
--text:#e6edf3; --muted:#8b949e; --accent:#00b8b0; --green:#3fb950; --red:#f85149; --amber:#d29922;
--radius:8px; --radius-lg:12px;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:var(--bg); color:var(--text);
font-family:"Satoshi","Familjen Grotesk",system-ui,sans-serif;
font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased;
}
h1,h2,.headline{font-family:"Familjen Grotesk","Satoshi",sans-serif;font-weight:600}
.mono{font-family:"JetBrains Mono",ui-monospace,monospace}
button{font-family:inherit;cursor:pointer}
.wrap{max-width:1180px;margin:0 auto;padding:16px}
/* header — canonical single-row Adom app header */
header{display:flex;align-items:center;gap:12px;height:44px;padding:0 2px;margin-bottom:16px;
position:relative;font-family:"Familjen Grotesk",sans-serif}
header .brand{display:flex;align-items:center;gap:8px;color:var(--accent);font-size:13px;font-weight:600;
letter-spacing:.01em;cursor:default;flex-shrink:0;outline:none}
header .brand:hover .app-name{text-decoration:underline;text-decoration-color:var(--accent);text-underline-offset:3px}
header .brand .logo{width:18px;height:18px;flex-shrink:0;color:var(--text)}
header .brand .app-name{color:var(--accent)}
header .brand .app-subtitle{color:var(--muted);font-weight:500}
header .brand .sep{color:var(--muted);font-weight:400}
header .vsep{width:1px;height:18px;background:var(--border);flex-shrink:0}
header .subject{display:flex;align-items:center;gap:8px;min-width:0;flex:1;cursor:default}
header .subject-name{color:var(--text);font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
header .subject-meta{display:flex;align-items:center;gap:6px;color:var(--muted);font-family:"JetBrains Mono",monospace;font-size:11px;white-space:nowrap}
header .subject-meta .sep{color:var(--border)}
header .actions{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0}
@media (max-width:720px){header .subject-meta{display:none}}
header .hover-popover{position:absolute;top:calc(100% + 6px);left:0;background:var(--surface2);
border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:12px 14px;
min-width:300px;max-width:420px;font-family:"Satoshi",sans-serif;font-size:12px;color:var(--text);z-index:99999;
opacity:0;transform:translateY(-2px);pointer-events:none;transition:opacity .12s ease,transform .12s ease}
header .hover-popover.show{opacity:1;transform:none;pointer-events:auto}
header .hover-popover .popover-title{font-family:"Familjen Grotesk";font-size:14px;font-weight:700;color:var(--text);margin-bottom:8px}
header .hover-popover .popover-row{display:flex;gap:12px;padding:3px 0;align-items:baseline}
header .hover-popover .popover-row .k{color:var(--muted);font-family:"JetBrains Mono",monospace;font-size:10px;width:60px;flex-shrink:0}
header .hover-popover .popover-row .v{color:var(--text);font-size:12px;flex:1}
header .hover-popover .popover-actions{margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:12px}
header .hover-popover .popover-link{color:var(--accent);text-decoration:none;font-family:"Familjen Grotesk";font-size:12px;font-weight:600}
header .hover-popover .popover-link:hover{text-decoration:underline}
.pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;
font-size:11.5px;border:1px solid var(--border);background:var(--surface);color:var(--muted)}
.pill.demo{border-color:rgba(210,153,34,.4);color:var(--amber)}
.pill.live{border-color:rgba(63,185,80,.4);color:var(--green)}
.dot{width:7px;height:7px;border-radius:50%;background:currentColor}
/* toolbar */
.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.chips{display:inline-flex;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:3px}
.chip{border:none;background:transparent;color:var(--muted);padding:5px 12px;border-radius:6px;font-size:13px;transition:.15s}
.chip:hover{color:var(--text)}
.chip.on{background:var(--surface2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)}
.chip .ct{color:var(--muted);font-size:11px;margin-left:5px}
.toolbar .spacer{flex:1}
.btn{border:1px solid var(--border);background:var(--surface);color:var(--text);padding:6px 12px;
border-radius:var(--radius);font-size:13px;transition:.15s;display:inline-flex;align-items:center;gap:6px}
.btn:hover{border-color:var(--accent);color:#fff}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn.ghost{background:transparent}
.btn svg{width:15px;height:15px}
/* table */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
table{width:100%;border-collapse:collapse}
thead th{text-align:left;font-size:11px;text-transform:none;letter-spacing:.02em;color:var(--muted);
font-weight:500;padding:10px 12px;border-bottom:1px solid var(--border);background:var(--surface2)}
tbody td{padding:11px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover{background:rgba(255,255,255,.02)}
.dev-name{font-weight:600}
.dev-sub{color:var(--muted);font-size:12px;margin-top:1px}
.badge{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:6px;font-size:11.5px;
border:1px solid var(--border);background:var(--surface2);color:var(--muted)}
.badge.local{color:#9ad0ff;border-color:rgba(56,139,253,.35)}
.badge.cloud{color:#c6a6ff;border-color:rgba(163,113,247,.35)}
.badge.mounted{color:var(--green);border-color:rgba(63,185,80,.35)}
.badge.unmounted{color:var(--muted)}
.badge.demo{color:var(--amber);border-color:rgba(210,153,34,.35)}
select.target{background:var(--surface2);color:var(--text);border:1px solid var(--border);
border-radius:6px;padding:5px 8px;font-size:12.5px;max-width:200px}
select.target:disabled{opacity:.5}
.actions{display:flex;align-items:center;gap:5px;flex-wrap:wrap}
.iconbtn{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;
border:1px solid var(--border);background:var(--surface2);color:var(--muted);border-radius:6px;transition:.15s}
.iconbtn:hover:not(:disabled){border-color:var(--accent);color:#fff}
.iconbtn:disabled{opacity:.35;cursor:not-allowed}
.iconbtn svg{width:15px;height:15px}
.mountbtn{padding:5px 10px;font-size:12.5px;border-radius:6px;border:1px solid var(--border);
background:var(--accent);color:#04211f;font-weight:600;transition:.15s}
.mountbtn:hover:not(:disabled){filter:brightness(1.08)}
.mountbtn:disabled{opacity:.4;cursor:not-allowed}
.mountbtn.un{background:transparent;color:var(--text)}
select.cls{background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:11.5px}
.empty{padding:40px;text-align:center;color:var(--muted)}
/* action log */
.log{margin-top:16px}
.log summary{cursor:pointer;color:var(--muted);font-size:12.5px;padding:6px 0;list-style:none}
.log summary::-webkit-details-marker{display:none}
.log summary:hover{color:var(--text)}
.logbox{background:#0a0d12;border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;
max-height:200px;overflow:auto;font-size:12px}
.logrow{padding:3px 0;border-bottom:1px solid rgba(255,255,255,.04);display:flex;gap:8px}
.logrow:last-child{border-bottom:none}
.logop{color:var(--accent);min-width:62px;font-weight:600}
.logcmd{color:var(--muted)}
/* tooltip (body-appended, 500ms delay, fade) */
#tip{position:fixed;z-index:99999;max-width:300px;background:#05080d;color:var(--text);
border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.45;
pointer-events:none;opacity:0;transition:opacity .18s ease;box-shadow:0 8px 24px rgba(0,0,0,.5)}
#tip.show{opacity:1}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#05080d;border:1px solid var(--border);
padding:9px 14px;border-radius:8px;font-size:13px;opacity:0;transition:.2s;pointer-events:none;z-index:9999}
.toast.show{opacity:1}
.toast.err{border-color:rgba(248,81,73,.5);color:#ffb3ae}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand" data-tooltip-target="app-info" tabindex="0">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 21V5"/><path d="M12 5l-2.4 3h4.8L12 5z" fill="currentColor" stroke="none"/>
<circle cx="12" cy="22" r="0.9" fill="currentColor" stroke="none"/>
<path d="M12 14l-4.2-2.4V9.2"/><rect x="6.4" y="6.6" width="2.8" height="2.6" rx="0.4" fill="currentColor" stroke="none"/>
<path d="M12 11l4.2-2.4V6.4"/><circle cx="16.2" cy="5.4" r="1.1" fill="currentColor" stroke="none"/>
</svg>
<span class="app-name">adom-usb</span>
<span class="sep">·</span>
<span class="app-subtitle">USB device mesh</span>
</div>
<div class="vsep" aria-hidden="true"></div>
<div class="subject">
<span class="subject-name">Device mesh</span>
<span class="subject-meta" id="subjectMeta"><span class="sep">·</span><span id="smTotal">0 devices</span></span>
</div>
<div class="actions">
<span id="modepill" class="pill"><span class="dot"></span><span id="modetext">…</span></span>
</div>
<div class="hover-popover" id="app-info" role="dialog" aria-label="App information">
<div class="popover-title">adom-usb</div>
<div class="popover-row"><span class="k">about</span><span class="v">See every USB device across the Adom ecosystem — laptop, WSL2, cloud container, Azure VM, workcells — and mount / reset / test / force-class / sniff any of them from one place.</span></div>
<div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.0</span></div>
<div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>
<div class="popover-actions">
<a class="popover-link" href="https://wiki.adom.inc/apps/adom-usb" target="_blank" rel="noopener">Open wiki ↗</a>
<a class="popover-link" href="https://github.com/adom-inc/adom-usb" target="_blank" rel="noopener">GitHub ↗</a>
</div>
</div>
</header>
<div class="toolbar">
<div class="chips" id="filters">
<button class="chip on" data-f="all" aria-pressed="true" data-tooltip="Show every USB device across the whole mesh.">All <span class="ct" id="ct-all">0</span></button>
<button class="chip" data-f="local" aria-pressed="false" data-tooltip="Local — devices physically plugged into your own machine (laptop and its WSL2 / Hydrogen Desktop).">Local <span class="ct" id="ct-local">0</span></button>
<button class="chip" data-f="cloud" aria-pressed="false" data-tooltip="Cloud — devices on Adom-side hosts: this container, the Azure VM and its WSL2, and workcells.">Cloud <span class="ct" id="ct-cloud">0</span></button>
</div>
<div class="spacer"></div>
<button class="btn ghost" id="refresh" data-tooltip="Re-enumerate USB devices across every host and refresh the table.">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 4v5h-5"/></svg>
Refresh
</button>
</div>
<div class="card">
<table>
<thead><tr>
<th style="width:30%">Device</th>
<th>VID:PID</th>
<th>Serial</th>
<th>Class</th>
<th>Source / location</th>
<th>Mounted on</th>
<th style="width:280px">Mount target & actions</th>
</tr></thead>
<tbody id="rows"><tr><td colspan="7" class="empty">Loading…</td></tr></tbody>
</table>
</div>
<details class="log" id="logwrap">
<summary id="logsummary">Action log — agent commands</summary>
<div class="logbox" id="logbox"></div>
</details>
</div>
<div id="tip"></div>
<div id="toast" class="toast"></div>
<script>
// ---- console forwarding (so the AI can read UI logs without DevTools) ----
(function(){
const send=(level,args)=>{try{fetch('console',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({level,msg:args.map(a=>typeof a==='object'?JSON.stringify(a):String(a)).join(' '),ts:Date.now()})});}catch(e){}};
['log','warn','error'].forEach(l=>{const o=console[l].bind(console);console[l]=(...a)=>{o(...a);send(l,a);};});
window.onerror=(m,s,ln,c,e)=>send('error',[m,'@',s+':'+ln,e&&e.stack||'']);
window.addEventListener('unhandledrejection',ev=>send('error',['unhandledrejection',ev.reason&&ev.reason.stack||ev.reason]));
})();
// ---- tooltip engine: 500ms hover delay, fade, body-appended ----
const tip=document.getElementById('tip');let tipTimer=null,tipTarget=null;
function showTip(el){const t=el.getAttribute('data-tooltip');if(!t)return;tip.textContent=t;tip.classList.add('show');
const r=el.getBoundingClientRect();let x=r.left,y=r.bottom+8;const tw=tip.offsetWidth,th=tip.offsetHeight;
if(x+tw>innerWidth-8)x=innerWidth-tw-8;if(x<8)x=8;if(y+th>innerHeight-8)y=r.top-th-8;
tip.style.left=x+'px';tip.style.top=Math.max(8,y)+'px';}
function hideTip(){tip.classList.remove('show');tipTarget=null;}
document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-tooltip]');if(!el){return;}
if(el===tipTarget)return;tipTarget=el;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{if(tipTarget===el)showTip(el);},500);});
document.addEventListener('mouseout',e=>{const el=e.target.closest('[data-tooltip]');if(el&&el===tipTarget){clearTimeout(tipTimer);hideTip();}});
window.addEventListener('scroll',hideTip,true);
// ---- interactive header popover (hover-tooltip you can move into) ----
(function(){
const SHOW_DELAY=600,HIDE_GRACE=220;
document.querySelectorAll('header [data-tooltip-target]').forEach(trigger=>{
const target=document.getElementById(trigger.dataset.tooltipTarget);if(!target)return;
let showT=null,hideT=null;
const show=()=>{clearTimeout(hideT);target.classList.add('show');};
const queueShow=()=>{clearTimeout(hideT);showT=setTimeout(show,SHOW_DELAY);};
const queueHide=()=>{clearTimeout(showT);hideT=setTimeout(()=>target.classList.remove('show'),HIDE_GRACE);};
trigger.addEventListener('mouseenter',queueShow);
trigger.addEventListener('mouseleave',queueHide);
trigger.addEventListener('click',()=>{clearTimeout(showT);target.classList.toggle('show');});
target.addEventListener('mouseenter',show);
target.addEventListener('mouseleave',queueHide);
});
})();
// ---- toast ----
const toastEl=document.getElementById('toast');let toastTimer=null;
function toast(msg,isErr){toastEl.textContent=msg;toastEl.classList.toggle('err',!!isErr);toastEl.classList.add('show');
clearTimeout(toastTimer);toastTimer=setTimeout(()=>toastEl.classList.remove('show'),2600);}
// ---- state ----
let filter='all';let lastTs=-1;let picker={}; // device_id -> selected target host
const ICONS={
reset:'<path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 4v5h-5"/>',
test:'<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
sniff:'<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>'
};
function icon(p){return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'+p+'</svg>';}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
async function post(path,body){
const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
const v=await r.json().catch(()=>({}));return{ok:r.ok,status:r.status,v};
}
async function doMount(id){
const target=picker[id];if(!target){toast('pick a target first',true);return;}
const{ok,v}=await post('mount',{device_id:id,target_id:target});
if(ok){toast('mounted → '+target);}else{toast(v.error||'mount failed',true);}render();
}
async function doUnmount(id){const{ok,v}=await post('unmount',{device_id:id});
if(ok){toast('unmounted');}else{toast(v.error||'unmount failed',true);}render();}
async function doOp(id,op){const path=op==='class_override'?'class-override':op;
const{ok,v}=await post(path,{device_id:id});
if(ok){toast(op+': '+(v.command||'queued'));}else{toast(v.error||(op+' failed'),true);}render();}
async function doFlash(id){const sel=document.getElementById('fw-'+id);const persona=sel&&sel.value;
if(!persona){toast('pick a persona',true);return;}
const{ok,v}=await post('flash',{device_id:id,persona});
if(ok){toast('flashing → '+persona+' ('+(v.plugin||'')+')');}else{toast(v.error||'flash failed',true);}render();}
function setFilter(f){filter=f;document.querySelectorAll('.chip').forEach(c=>{const on=c.dataset.f===f;c.classList.toggle('on',on);c.setAttribute('aria-pressed',on?'true':'false');});render();}
document.getElementById('filters').addEventListener('click',e=>{const c=e.target.closest('.chip');if(c)setFilter(c.dataset.f);});
document.getElementById('refresh').addEventListener('click',async()=>{await post('refresh',{});toast('refreshed');force();});
let SNAP={hosts:[],devices:[],actions:[]};
function hostLabel(id){const h=SNAP.hosts.find(h=>h.id===id);return h?h.label:id;}
function render(){
const counts={all:SNAP.devices.length,
local:SNAP.devices.filter(d=>d.location==='local').length,
cloud:SNAP.devices.filter(d=>d.location==='cloud').length};
document.getElementById('ct-all').textContent=counts.all;
document.getElementById('ct-local').textContent=counts.local;
document.getElementById('ct-cloud').textContent=counts.cloud;
const list=SNAP.devices.filter(d=>filter==='all'||d.location===filter);
const rows=document.getElementById('rows');
if(!list.length){rows.innerHTML='<tr><td colspan="7" class="empty">No '+esc(filter==='all'?'':filter)+' USB devices. Plug one in, or run <span class="mono">adom-usb app --demo</span>.</td></tr>';}
else rows.innerHTML=list.map(rowHtml).join('');
// action log
const lb=document.getElementById('logbox');
if(SNAP.actions&&SNAP.actions.length){
lb.innerHTML=SNAP.actions.map(a=>'<div class="logrow"><span class="logop">'+esc(a.op)+'</span><span class="logcmd mono">'+esc(a.command||'')+'</span></div>').join('');
document.getElementById('logsummary').textContent='Action log — '+SNAP.actions.length+' recent agent command'+(SNAP.actions.length>1?'s':'');
}else{lb.innerHTML='<div class="logcmd">No actions yet. Mount a device to see the exact agent command it runs.</div>';}
}
function rowHtml(d){
const caps=d.capabilities||{};
const mounted=d.mounted_on;
const sel=picker[d.id]||(d.targets[0]&&d.targets[0].host_id)||'';
// target picker
let opts=d.targets.map(t=>{
const dis=t.blocked_reason?' disabled':'';
const label=t.blocked_reason?(t.label+' — unavailable'):t.label;
return '<option value="'+esc(t.host_id)+'"'+(t.host_id===sel?' selected':'')+dis+'>'+esc(label)+'</option>';
}).join('');
const selTip=(d.targets.find(t=>t.host_id===sel)||{}).mechanism;
const mech={'usbipd-attach-wsl':'Local attach (usbipd --wsl), no relay','reverse-tunnel':'Cross-machine via adom-desktop reverse tunnel','native':'Native on the host it is plugged into','direct-tcp':'Direct USB/IP TCP'}[selTip]||'';
const mountedRemote = mounted && mounted!==d.source_id;
let mountCell;
if(mountedRemote){
mountCell='<button class="mountbtn un" onclick="doUnmount(\''+d.id+'\')" data-tooltip="Detach this device from '+esc(hostLabel(mounted))+' and return it to its source host ('+esc(d.source_label)+').">Unmount</button>';
} else if(!d.exportable){
mountCell='<span class="badge" data-tooltip="This device lives natively on '+esc(d.source_label)+' and has no remote export agent (cloud containers / WSL2 distros are consumers, not USB sources). Nothing to route.">native — on its host</span>';
} else {
mountCell='<select class="target" onchange="picker[\''+d.id+'\']=this.value;render()" data-tooltip="Choose where to mount this device. '+esc(mech)+'">'+opts+'</select>'+
'<button class="mountbtn" onclick="doMount(\''+d.id+'\')" data-tooltip="Mount this device on the selected target. '+esc(mech)+'.">Mount</button>';
}
const opBtn=(op,tipOn,tipOff)=>{
const en=!!caps[op];
return '<button class="iconbtn" '+(en?'':'disabled ')+'onclick="doOp(\''+d.id+'\',\''+op+'\')" data-tooltip="'+esc(en?tipOn:tipOff)+'">'+icon(ICONS[op]||'')+'</button>';
};
const offReason=caps.reason||'Mount to a Linux target first.';
const clsSel='<select class="cls" id="cls-'+d.id+'" '+(caps.class_override?'':'disabled ')+'data-tooltip="Force the kernel to bind this device as a specific class regardless of its default, via driver_override. '+esc(caps.class_override?'':offReason)+'"><option value="hid">HID</option><option value="storage">Mass storage</option><option value="serial">Serial</option></select>';
const clsBtn='<button class="iconbtn" '+(caps.class_override?'':'disabled ')+'onclick="doOp(\''+d.id+'\',\'class_override\')" data-tooltip="Apply the selected class override.">'+icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>')+'</button>';
// Firmware device-test pack: flash a stub USB persona to this chip.
const personas=d.firmware_personas||[];
let flashCtl='';
if(personas.length){
const opts=personas.map(p=>'<option value="'+esc(p.id)+'">'+esc(p.name||p.id)+'</option>').join('');
flashCtl='<select class="cls" id="fw-'+d.id+'" data-tooltip="Stub USB personas '+esc(d.firmware_plugin||'')+' can flash to this chip — each makes it enumerate as a different USB class to test host support.">'+opts+'</select>'+
'<button class="iconbtn" onclick="doFlash(\''+d.id+'\')" data-tooltip="Flash the selected stub firmware persona ('+esc(d.firmware_plugin||'')+'). The device re-enumerates as that USB class so you can mount / test / sniff it.">'+icon('<path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/>')+'</button>';
}
const srcBadge='<span class="badge '+esc(d.location)+'" data-tooltip="Physically plugged into '+esc(d.source_label)+'.">'+esc(d.source_label)+'</span>';
const mountedBadge=mounted
?'<span class="badge mounted" data-tooltip="Currently attached to '+esc(hostLabel(mounted))+'.">'+esc(hostLabel(mounted))+'</span>'
:'<span class="badge unmounted">— not mounted —</span>';
const demoBadge=d.demo?' <span class="badge demo" data-tooltip="Seeded demo device (not real hardware). Run without --demo for live inventory only.">demo</span>':'';
return '<tr>'+
'<td><div class="dev-name">'+esc(d.name)+demoBadge+'</div><div class="dev-sub">'+esc(d.class)+(d.bus_id?(' · bus '+esc(d.bus_id)):'')+'</div></td>'+
'<td class="mono">'+esc(d.vid_hex)+':'+esc(d.pid_hex)+'</td>'+
'<td class="mono">'+esc(d.serial||'—')+'</td>'+
'<td>'+esc(d.class)+'</td>'+
'<td>'+srcBadge+'</td>'+
'<td>'+mountedBadge+'</td>'+
'<td><div class="actions">'+mountCell+
opBtn('reset','Power-cycle / re-enumerate this device on '+esc(hostLabel(mounted||''))+'.',offReason)+
opBtn('test','Run lsusb -v + an enumerate/echo health check on the target.',offReason)+
clsSel+clsBtn+
opBtn('sniff',(d.sniffer_plugin?('Capture + decode USB packets via plugin '+d.sniffer_plugin+'.'):'Capture USB packets (usbmon) for this device on the target → pcap.'),offReason)+
flashCtl+
'</div></td>'+
'</tr>';
}
async function poll(){
try{
const r=await fetch('state');const s=await r.json();
document.getElementById('modetext').textContent=s.demo?'demo data':'live';
const mp=document.getElementById('modepill');mp.className='pill '+(s.demo?'demo':'live');
const c=s.counts||{};document.getElementById('smTotal').textContent=
(c.total||0)+' device'+((c.total||0)===1?'':'s')+' · '+(c.local||0)+' local · '+(c.cloud||0)+' cloud · '+(c.mounted||0)+' mounted';
if(s.state_ts!==lastTs){lastTs=s.state_ts;SNAP=s;render();}
}catch(e){/* transient */}
}
function force(){lastTs=-1;poll();}
poll();setInterval(poll,600);
</script>
</body>
</html>