feat: full request/response inspection with detail panel, hex dump, JSON pretty-print

This commit is contained in:
Majjo
2026-04-18 19:13:34 +02:00
parent 768a3b7c09
commit ce3361992c

View File

@@ -23,7 +23,9 @@ class CompanionServer : Feature("CompanionServer") {
// Ring buffers for replay on new connections
private val recentLogs = ArrayDeque<Map<String, String>>(500)
private val recentNetworkCalls = ArrayDeque<Map<String, String>>(200)
private val recentNetworkCalls = ArrayDeque<MutableMap<String, Any?>>(200)
// id → full entry (includes bodies), evicted when ring buffer evicts
private val networkById = java.util.concurrent.ConcurrentHashMap<String, MutableMap<String, Any?>>()
private val bufferLock = Any()
// Prevent re-entrant log capture while the server itself logs
@@ -32,6 +34,26 @@ class CompanionServer : Feature("CompanionServer") {
// Active sessions — UUID created on POST /login, removed on /logout or server restart
private val validSessions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
// Strip large body fields for lightweight SSE / history listing
private fun networkSummary(e: MutableMap<String, Any?>) = mapOf(
"id" to e["id"], "ts" to e["ts"], "type" to e["type"],
"method" to e["method"], "uri" to e["uri"],
"status" to e["status"], "reqSize" to e["reqSize"],
"resSize" to e["resSize"], "done" to e["done"]
)
private fun pushNetworkEntry(entry: MutableMap<String, Any?>) {
val id = entry["id"] as String
networkById[id] = entry
synchronized(bufferLock) {
if (recentNetworkCalls.size >= 200) {
networkById.remove(recentNetworkCalls.removeFirst()["id"] as? String)
}
recentNetworkCalls.addLast(entry)
}
broadcast("network", networkSummary(entry))
}
override fun init() {
val config = context.config.companionServer
if (!config.enabled.get()) {
@@ -96,32 +118,62 @@ class CompanionServer : Feature("CompanionServer") {
// ---- Network event capture ----
private fun subscribeToNetworkEvents() {
val bodyLimit = 48 * 1024
fun encodeBody(bytes: ByteArray): String {
val slice = if (bytes.size <= bodyLimit) bytes else bytes.copyOf(bodyLimit)
val b64 = java.util.Base64.getEncoder().encodeToString(slice)
return if (bytes.size > bodyLimit) "$b64==TRUNCATED==" else b64
}
context.event.subscribe(NativeUnaryCallEvent::class) { event ->
val entry = mapOf(
"ts" to System.currentTimeMillis().toString(),
"type" to "grpc",
"uri" to event.uri,
"size" to event.buffer.size.toString()
val entry = mutableMapOf<String, Any?>(
"id" to java.util.UUID.randomUUID().toString(),
"ts" to System.currentTimeMillis().toString(),
"type" to "grpc",
"method" to "GRPC",
"uri" to event.uri,
"status" to null,
"reqSize" to event.buffer.size,
"resSize" to null,
"reqBody" to encodeBody(event.buffer),
"resBody" to null,
"done" to true
)
synchronized(bufferLock) {
if (recentNetworkCalls.size >= 200) recentNetworkCalls.removeFirst()
recentNetworkCalls.addLast(entry)
}
broadcast("network", entry)
pushNetworkEntry(entry)
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
val entry = mapOf(
"ts" to System.currentTimeMillis().toString(),
"type" to "http",
"uri" to event.url,
"size" to "0"
val hasBody = event.uploadDataProvider != null
var reqBodyB64: String? = null
if (hasBody) {
event.hookRequestBuffer { buffer ->
reqBodyB64 = encodeBody(buffer)
buffer // return unchanged
}
}
val entry = mutableMapOf<String, Any?>(
"id" to java.util.UUID.randomUUID().toString(),
"ts" to System.currentTimeMillis().toString(),
"type" to "http",
"method" to if (hasBody) "POST" else "GET",
"uri" to event.url,
"status" to null,
"reqSize" to null,
"resSize" to null,
"reqBody" to reqBodyB64,
"resBody" to null,
"done" to false
)
synchronized(bufferLock) {
if (recentNetworkCalls.size >= 200) recentNetworkCalls.removeFirst()
recentNetworkCalls.addLast(entry)
pushNetworkEntry(entry)
event.onSuccess { responseBuffer ->
entry["status"] = 200
entry["resSize"] = responseBuffer?.size
entry["resBody"] = responseBuffer?.let { encodeBody(it) }
entry["done"] = true
broadcast("network_update", networkSummary(entry))
}
broadcast("network", entry)
}
}
@@ -208,11 +260,16 @@ class CompanionServer : Feature("CompanionServer") {
return
}
when (rawPath) {
"/events" -> handleSSE(socket)
"/logs" -> serveJson(socket, synchronized(bufferLock) { recentLogs.toList() })
"/network" -> serveJson(socket, synchronized(bufferLock) { recentNetworkCalls.toList() })
else -> respond404(socket)
when {
rawPath == "/events" -> handleSSE(socket)
rawPath == "/logs" -> serveJson(socket, synchronized(bufferLock) { recentLogs.toList() })
rawPath == "/network" -> serveJson(socket, synchronized(bufferLock) { recentNetworkCalls.map { networkSummary(it) } })
rawPath.startsWith("/network/") -> {
val id = rawPath.removePrefix("/network/")
val entry = networkById[id]
if (entry != null) serveJson(socket, entry) else respond404(socket)
}
else -> respond404(socket)
}
}
}
@@ -396,23 +453,50 @@ body{font-family:monospace;background:#0d0d0d;color:#d0d0d0;height:100vh;display
#dot.on{color:#4caf50}
#dot.off{color:#ef5350}
#counts{font-size:10px;color:#555;margin-left:auto}
#logout{font-size:10px;padding:2px 8px;background:#181818;border:1px solid #333;color:#888;cursor:pointer;border-radius:2px;text-decoration:none}
#logout:hover{background:#222}
#main{display:flex;flex:1;overflow:hidden}
a#logout{font-size:10px;padding:2px 8px;background:#181818;border:1px solid #333;color:#888;cursor:pointer;border-radius:2px;text-decoration:none}
a#logout:hover{background:#222}
#main{display:flex;flex:1;overflow:hidden;min-height:0}
.pane{flex:1;display:flex;flex-direction:column;border-right:1px solid #1a1a1a;overflow:hidden}
.pane:last-child{border-right:none}
.pane-bar{padding:4px 8px;background:#111;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:6px;flex-shrink:0}
.pane-bar span{font-size:11px;color:#888;min-width:50px}
.pane-bar span.title{font-size:11px;color:#888;min-width:50px}
.pane-bar input{flex:1;background:#0d0d0d;border:1px solid #2a2a2a;color:#bbb;font-family:monospace;font-size:11px;padding:2px 5px;border-radius:2px;outline:none}
.pane-bar input:focus{border-color:#444}
.pane-bar button{font-size:10px;padding:2px 7px;background:#181818;border:1px solid #333;color:#888;cursor:pointer;border-radius:2px}
.pane-bar button:hover{background:#222}
.pane-bar label{font-size:10px;color:#555;white-space:nowrap}
.entries{flex:1;overflow-y:auto;padding:2px 0;font-size:11px;line-height:1.6}
.row{padding:0 6px;white-space:pre-wrap;word-break:break-all}
.row:hover{background:#141414}
/* Log rows */
.log-row{padding:0 6px;white-space:pre-wrap;word-break:break-all}
.log-row:hover{background:#141414}
.V{color:#555}.D{color:#5c8fbd}.I{color:#bbb}.W{color:#c9944a}.E{color:#c45252}.A{color:#c45252;font-weight:bold}
.grpc{color:#9c6db5}.http{color:#3ba8b8}
/* Network rows */
.net-row{display:flex;align-items:baseline;padding:0 6px;gap:6px;cursor:pointer;white-space:nowrap;overflow:hidden}
.net-row:hover{background:#141414}
.net-row.selected{background:#181c24}
.badge{font-size:9px;padding:1px 4px;border-radius:2px;font-weight:bold;flex-shrink:0}
.GRPC{background:#2a1a3e;color:#9c6db5}
.GET{background:#0d2a1a;color:#4caf50}
.POST{background:#1a1a0d;color:#c9944a}
.PUT{background:#1a0d0d;color:#c45252}
.status-ok{color:#4caf50;font-size:10px;flex-shrink:0}
.status-err{color:#c45252;font-size:10px;flex-shrink:0}
.status-pend{color:#555;font-size:10px;flex-shrink:0}
.net-uri{font-size:10px;color:#888;overflow:hidden;text-overflow:ellipsis;flex:1}
.net-size{font-size:9px;color:#444;flex-shrink:0}
/* Detail panel */
#detail{flex-direction:column;flex-shrink:0;height:45%;border-top:2px solid #2a2a2a;background:#0a0a0a;overflow:hidden}
#detail-bar{padding:4px 8px;background:#111;border-bottom:1px solid #222;display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex-shrink:0}
#d-badge{font-size:9px;padding:1px 5px;border-radius:2px;font-weight:bold}
#d-status{font-size:10px;min-width:32px}
#d-uri{font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tab-btn{font-size:10px;padding:2px 7px;background:#181818;border:1px solid #333;color:#888;cursor:pointer;border-radius:2px}
.tab-btn.active{background:#222;color:#ccc;border-color:#555}
.tab-btn:hover{background:#222}
#d-body{flex:1;overflow:auto;padding:6px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:#bbb;line-height:1.5}
#d-body .key{color:#5c8fbd}
#d-body .str{color:#4caf50}
#d-body .num{color:#c9944a}
</style>
</head>
<body>
@@ -425,7 +509,7 @@ body{font-family:monospace;background:#0d0d0d;color:#d0d0d0;height:100vh;display
<div id="main">
<div class="pane">
<div class="pane-bar">
<span>Logs</span>
<span class="title">Logs</span>
<input id="lf" type="text" placeholder="filter…" oninput="filter('le','lf')"/>
<button onclick="clr('le')">Clear</button>
<label><input type="checkbox" id="ls" checked> scroll</label>
@@ -434,16 +518,29 @@ body{font-family:monospace;background:#0d0d0d;color:#d0d0d0;height:100vh;display
</div>
<div class="pane">
<div class="pane-bar">
<span>Network</span>
<input id="nf" type="text" placeholder="filter…" oninput="filter('ne','nf')"/>
<button onclick="clr('ne')">Clear</button>
<span class="title">Network</span>
<input id="nf" type="text" placeholder="filter…" oninput="filterNet()"/>
<button onclick="clr('ne');netMap={};detailId=null;q('detail').style.display='none'">Clear</button>
<label><input type="checkbox" id="ns" checked> scroll</label>
</div>
<div class="entries" id="ne"></div>
</div>
</div>
<div id="detail" style="display:none">
<div id="detail-bar">
<span id="d-badge" class="badge"></span>
<span id="d-status"></span>
<span id="d-uri"></span>
<button class="tab-btn active" id="tab-res" onclick="showTab('res')">Response</button>
<button class="tab-btn" id="tab-req" onclick="showTab('req')">Request</button>
<button class="tab-btn" id="btn-hex" onclick="toggleHex()">Hex</button>
<button class="tab-btn" onclick="copyBody()">Copy</button>
<button class="tab-btn" onclick="closeDetail()">✕</button>
</div>
<div id="d-body"></div>
</div>
<script>
var lc=0,nc=0;
var lc=0,nc=0,netMap={},detailId=null,detailTab='res',hexMode=false,detailFull=null;
function ts(e){return new Date(+e).toISOString().slice(11,23)}
function q(id){return document.getElementById(id)}
function loadHistory(){
@@ -456,35 +553,361 @@ function startSSE(){
es.onerror=function(){q('dot').textContent='● disconnected';q('dot').className='off';};
es.addEventListener('log',function(e){addLog(JSON.parse(e.data),true);});
es.addEventListener('network',function(e){addNet(JSON.parse(e.data),true);});
es.addEventListener('network_update',function(e){updateNet(JSON.parse(e.data));});
}
// ---- Logs ----
function addLog(e,live){
lc++;
var el=document.createElement('div');
el.className='row '+(e.level||'I');
el.className='log-row '+(e.level||'I');
el.textContent='['+ts(e.ts)+'] '+(e.level||'I')+' '+(e.tag||'')+': '+(e.msg||'');
el.dataset.t=el.textContent.toLowerCase();
var f=q('lf').value.toLowerCase();
if(f&&!el.dataset.t.includes(f))el.style.display='none';
applyLogFilter(el);
q('le').appendChild(el);
if(live&&q('ls').checked)el.scrollIntoView();
upd();
}
function applyLogFilter(el){
var f=q('lf').value.toLowerCase();
el.style.display=(!f||el.dataset.t.includes(f))?'':'none';
}
// ---- Network rows ----
function methodClass(m){return m||'GET';}
function statusSpan(e){
if(!e.done)return '<span class="status-pend">…</span>';
if(e.status&&e.status>=400)return '<span class="status-err">'+e.status+'</span>';
return '<span class="status-ok">'+(e.status||'ok')+'</span>';
}
function sizeStr(bytes){
if(bytes==null)return '';
if(bytes<1024)return bytes+'B';
return (bytes/1024).toFixed(1)+'K';
}
function addNet(e,live){
nc++;
netMap[e.id]=e;
var el=document.createElement('div');
el.className='row '+(e.type||'grpc');
var sz=e.size&&e.size!=='0'?' ('+e.size+' B)':'';
el.textContent='['+ts(e.ts)+'] '+(e.type||'').toUpperCase()+' '+(e.uri||e.url||'')+sz;
el.dataset.t=el.textContent.toLowerCase();
var f=q('nf').value.toLowerCase();
if(f&&!el.dataset.t.includes(f))el.style.display='none';
el.className='net-row';
el.id='nr-'+e.id;
el.dataset.t=(e.uri||'').toLowerCase();
el.innerHTML='<span class="badge '+methodClass(e.method)+'">'+(e.method||'?')+'</span>'+
statusSpan(e)+
'<span class="net-uri">'+(e.uri||'')+'</span>'+
'<span class="net-size" id="ns-'+e.id+'">'+(e.resSize!=null?sizeStr(e.resSize):'')+'</span>';
el.onclick=function(){openDetail(e.id);};
applyNetFilter(el);
q('ne').appendChild(el);
if(live&&q('ns').checked)el.scrollIntoView();
upd();
}
function updateNet(e){
netMap[e.id]=Object.assign(netMap[e.id]||{},e);
var row=q('nr-'+e.id);
if(!row)return;
var m=netMap[e.id];
row.innerHTML='<span class="badge '+methodClass(m.method)+'">'+(m.method||'?')+'</span>'+
statusSpan(m)+
'<span class="net-uri">'+(m.uri||'')+'</span>'+
'<span class="net-size" id="ns-'+m.id+'">'+(m.resSize!=null?sizeStr(m.resSize):'')+'</span>';
row.onclick=function(){openDetail(m.id);};
if(detailId===e.id)refreshDetail();
}
function applyNetFilter(el){
var f=q('nf').value.toLowerCase();
el.style.display=(!f||el.dataset.t.includes(f))?'':'none';
}
function filterNet(){
q('ne').querySelectorAll('.net-row').forEach(applyNetFilter);
}
// ---- Detail panel ----
function openDetail(id){
detailId=id;
detailFull=null;
var sum=netMap[id];
if(!sum)return;
q('detail').style.display='flex';
q('ne').querySelectorAll('.net-row').forEach(function(r){r.classList.remove('selected');});
var row=q('nr-'+id);
if(row)row.classList.add('selected');
renderDetailMeta(sum);
q('d-body').textContent='Loading…';
fetch('/network/'+id).then(function(r){return r.json();}).then(function(full){
detailFull=full;
renderDetailMeta(full);
renderBody();
}).catch(function(){q('d-body').textContent='Failed to load details.';});
}
function refreshDetail(){
if(!detailId)return;
var sum=netMap[detailId];
if(!sum)return;
renderDetailMeta(sum);
if(detailFull)renderBody();
}
function renderDetailMeta(e){
var m=e.method||'?';
q('d-badge').className='badge '+methodClass(m);
q('d-badge').textContent=m;
q('d-badge').style.cssText='';
q('d-status').textContent=e.status||(e.done?'ok':'…');
q('d-status').className=e.status&&e.status>=400?'status-err':'status-ok';
q('d-uri').textContent=e.uri||'';
}
function showTab(tab){
detailTab=tab;
q('tab-req').className='tab-btn'+(tab==='req'?' active':'');
q('tab-res').className='tab-btn'+(tab==='res'?' active':'');
renderBody();
}
function toggleHex(){
hexMode=!hexMode;
q('btn-hex').className='tab-btn'+(hexMode?' active':'');
renderBody();
}
function renderBody(){
if(!detailFull){q('d-body').textContent='Loading…';return;}
var b64=detailTab==='res'?detailFull.resBody:detailFull.reqBody;
if(!b64){q('d-body').textContent='(empty)';return;}
var truncated=b64.endsWith('==TRUNCATED==');
if(truncated)b64=b64.slice(0,b64.length-13);
var bytes=b64ToBytes(b64);
var suffix=truncated?'\n\n[truncated at 48 KB]':'';
if(hexMode){q('d-body').textContent=hexDump(bytes)+suffix;return;}
var text=tryUtf8(bytes);
if(text){
var pretty=tryJsonPretty(text);
q('d-body').textContent=(pretty||text)+suffix;
}else{
q('d-body').textContent=hexDump(bytes)+suffix;
}
}
function b64ToBytes(b64){
var bin=atob(b64),bytes=new Uint8Array(bin.length);
for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);
return bytes;
}
function tryUtf8(bytes){
try{
var text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);
var printable=0;
for(var i=0;i<Math.min(text.length,200);i++){var c=text.charCodeAt(i);if(c>=32||c===10||c===13||c===9)printable++;}
return(printable/Math.min(text.length,200))>0.85?text:null;
}catch(e){return null;}
}
function tryJsonPretty(text){
try{return JSON.stringify(JSON.parse(text),null,2);}catch(e){return null;}
}
function hexDump(bytes){
var lines=[];
for(var i=0;i<bytes.length;i+=16){
var chunk=Array.from(bytes.slice(i,i+16));
var hex=chunk.map(function(b){return b.toString(16).padStart(2,'0');}).join(' ');
var asc=chunk.map(function(b){return b>=32&&b<127?String.fromCharCode(b):'.';}).join('');
lines.push(i.toString(16).padStart(8,'0')+' '+hex.padEnd(48)+' '+asc);
}
return lines.join('\n');
}
function copyBody(){
if(!detailFull)return;
var b64=detailTab==='res'?detailFull.resBody:detailFull.reqBody;
if(!b64)return;
if(b64.endsWith('==TRUNCATED=='))b64=b64.slice(0,b64.length-13);
var text=tryUtf8(b64ToBytes(b64))||b64;
navigator.clipboard&&navigator.clipboard.writeText(text);
}
function closeDetail(){
detailId=null;
detailFull=null;
q('detail').style.display='none';
q('ne').querySelectorAll('.net-row').forEach(function(r){r.classList.remove('selected');});
}
// ---- Shared ----
function filter(listId,filterId){
var f=q(filterId).value.toLowerCase();
q(listId).querySelectorAll('.row').forEach(function(el){
q(listId).querySelectorAll('.log-+e).toISOString().slice(11,23)}
function q(id){return document.getElementById(id)}
function loadHistory(){
fetch('/logs').then(function(r){return r.json();}).then(function(a){a.forEach(function(e){addLog(e,false);});});
fetch('/network').then(function(r){return r.json();}).then(function(a){a.forEach(function(e){addNet(e,false);});});
}
function startSSE(){
var es=new EventSource('/events');
es.onopen=function(){q('dot').textContent='● connected';q('dot').className='on';};
es.onerror=function(){q('dot').textContent='● disconnected';q('dot').className='off';};
es.addEventListener('log',function(e){addLog(JSON.parse(e.data),true);});
es.addEventListener('network',function(e){addNet(JSON.parse(e.data),true);});
es.addEventListener('network_update',function(e){updateNet(JSON.parse(e.data));});
}
// ---- Logs ----
function addLog(e,live){
lc++;
var el=document.createElement('div');
el.className='log-row '+(e.level||'I');
el.textContent='['+ts(e.ts)+'] '+(e.level||'I')+' '+(e.tag||'')+': '+(e.msg||'');
el.dataset.t=el.textContent.toLowerCase();
applyLogFilter(el);
q('le').appendChild(el);
if(live&&q('ls').checked)el.scrollIntoView();
upd();
}
function applyLogFilter(el){
var f=q('lf').value.toLowerCase();
el.style.display=(!f||el.dataset.t.includes(f))?'':'none';
}
// ---- Network rows ----
function methodClass(m){return m||'GET';}
function statusSpan(e){
if(!e.done)return '<span class="status-pend">…</span>';
if(e.status&&e.status>=400)return '<span class="status-err">'+e.status+'</span>';
return '<span class="status-ok">'+(e.status||'ok')+'</span>';
}
function sizeStr(bytes){
if(bytes==null)return '';
if(bytes<1024)return bytes+'B';
return (bytes/1024).toFixed(1)+'K';
}
function addNet(e,live){
nc++;
netMap[e.id]=e;
var el=document.createElement('div');
el.className='net-row';
el.id='nr-'+e.id;
el.dataset.t=(e.uri||'').toLowerCase();
el.innerHTML='<span class="badge '+methodClass(e.method)+'">'+(e.method||'?')+'</span>'+
statusSpan(e)+
'<span class="net-uri">'+(e.uri||'')+'</span>'+
'<span class="net-size" id="ns-'+e.id+'">'+(e.resSize!=null?sizeStr(e.resSize):'')+'</span>';
el.onclick=function(){openDetail(e.id);};
applyNetFilter(el);
q('ne').appendChild(el);
if(live&&q('ns').checked)el.scrollIntoView();
upd();
}
function updateNet(e){
netMap[e.id]=Object.assign(netMap[e.id]||{},e);
var row=q('nr-'+e.id);
if(!row)return;
var m=netMap[e.id];
row.innerHTML='<span class="badge '+methodClass(m.method)+'">'+(m.method||'?')+'</span>'+
statusSpan(m)+
'<span class="net-uri">'+(m.uri||'')+'</span>'+
'<span class="net-size" id="ns-'+m.id+'">'+(m.resSize!=null?sizeStr(m.resSize):'')+'</span>';
row.onclick=function(){openDetail(m.id);};
if(detailId===e.id)refreshDetail();
}
function applyNetFilter(el){
var f=q('nf').value.toLowerCase();
el.style.display=(!f||el.dataset.t.includes(f))?'':'none';
}
function filterNet(){
q('ne').querySelectorAll('.net-row').forEach(applyNetFilter);
}
// ---- Detail panel ----
function openDetail(id){
detailId=id;
detailFull=null;
var sum=netMap[id];
if(!sum)return;
q('detail').style.display='flex';
q('ne').querySelectorAll('.net-row').forEach(function(r){r.classList.remove('selected');});
var row=q('nr-'+id);
if(row)row.classList.add('selected');
renderDetailMeta(sum);
q('d-body').textContent='Loading…';
fetch('/network/'+id).then(function(r){return r.json();}).then(function(full){
detailFull=full;
renderDetailMeta(full);
renderBody();
}).catch(function(){q('d-body').textContent='Failed to load details.';});
}
function refreshDetail(){
if(!detailId)return;
var sum=netMap[detailId];
if(!sum)return;
renderDetailMeta(sum);
if(detailFull)renderBody();
}
function renderDetailMeta(e){
var m=e.method||'?';
q('d-badge').className='badge '+methodClass(m);
q('d-badge').textContent=m;
q('d-badge').style.cssText='';
q('d-status').textContent=e.status||(e.done?'ok':'…');
q('d-status').className=e.status&&e.status>=400?'status-err':'status-ok';
q('d-uri').textContent=e.uri||'';
}
function showTab(tab){
detailTab=tab;
q('tab-req').className='tab-btn'+(tab==='req'?' active':'');
q('tab-res').className='tab-btn'+(tab==='res'?' active':'');
renderBody();
}
function toggleHex(){
hexMode=!hexMode;
q('btn-hex').className='tab-btn'+(hexMode?' active':'');
renderBody();
}
function renderBody(){
if(!detailFull){q('d-body').textContent='Loading…';return;}
var b64=detailTab==='res'?detailFull.resBody:detailFull.reqBody;
if(!b64){q('d-body').textContent='(empty)';return;}
var truncated=b64.endsWith('==TRUNCATED==');
if(truncated)b64=b64.slice(0,b64.length-13);
var bytes=b64ToBytes(b64);
var suffix=truncated?'\n\n[truncated at 48 KB]':'';
if(hexMode){q('d-body').textContent=hexDump(bytes)+suffix;return;}
var text=tryUtf8(bytes);
if(text){
var pretty=tryJsonPretty(text);
q('d-body').textContent=(pretty||text)+suffix;
}else{
q('d-body').textContent=hexDump(bytes)+suffix;
}
}
function b64ToBytes(b64){
var bin=atob(b64),bytes=new Uint8Array(bin.length);
for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);
return bytes;
}
function tryUtf8(bytes){
try{
var text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);
var printable=0;
for(var i=0;i<Math.min(text.length,200);i++){var c=text.charCodeAt(i);if(c>=32||c===10||c===13||c===9)printable++;}
return(printable/Math.min(text.length,200))>0.85?text:null;
}catch(e){return null;}
}
function tryJsonPretty(text){
try{return JSON.stringify(JSON.parse(text),null,2);}catch(e){return null;}
}
function hexDump(bytes){
var lines=[];
for(var i=0;i<bytes.length;i+=16){
var chunk=Array.from(bytes.slice(i,i+16));
var hex=chunk.map(function(b){return b.toString(16).padStart(2,'0');}).join(' ');
var asc=chunk.map(function(b){return b>=32&&b<127?String.fromCharCode(b):'.';}).join('');
lines.push(i.toString(16).padStart(8,'0')+' '+hex.padEnd(48)+' '+asc);
}
return lines.join('\n');
}
function copyBody(){
if(!detailFull)return;
var b64=detailTab==='res'?detailFull.resBody:detailFull.reqBody;
if(!b64)return;
if(b64.endsWith('==TRUNCATED=='))b64=b64.slice(0,b64.length-13);
var text=tryUtf8(b64ToBytes(b64))||b64;
navigator.clipboard&&navigator.clipboard.writeText(text);
}
function closeDetail(){
detailId=null;
detailFull=null;
q('detail').style.display='none';
q('ne').querySelectorAll('.net-row').forEach(function(r){r.classList.remove('selected');});
}
// ---- Shared ----
function filter(listId,filterId){
var f=q(filterId).value.toLowerCase();
q(listId).querySelectorAll('.log-row').forEach(function(el){
el.style.display=(!f||el.dataset.t.includes(f))?'':'none';
});
}