feat: full request/response inspection with detail panel, hex dump, JSON pretty-print
This commit is contained in:
@@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user