From ce3361992c321a77fd1853d9e840ce48f4c3c01b Mon Sep 17 00:00:00 2001 From: Majjo <90888719+NoxRare@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:13:34 +0200 Subject: [PATCH] feat: full request/response inspection with detail panel, hex dump, JSON pretty-print --- .../features/impl/global/CompanionServer.kt | 517 ++++++++++++++++-- 1 file changed, 470 insertions(+), 47 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt index 749b6331..ce96e8c4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt @@ -23,7 +23,9 @@ class CompanionServer : Feature("CompanionServer") { // Ring buffers for replay on new connections private val recentLogs = ArrayDeque>(500) - private val recentNetworkCalls = ArrayDeque>(200) + private val recentNetworkCalls = ArrayDeque>(200) + // id → full entry (includes bodies), evicted when ring buffer evicts + private val networkById = java.util.concurrent.ConcurrentHashMap>() 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() + // Strip large body fields for lightweight SSE / history listing + private fun networkSummary(e: MutableMap) = 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) { + 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( + "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( + "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} @@ -425,7 +509,7 @@ body{font-family:monospace;background:#0d0d0d;color:#d0d0d0;height:100vh;display
- Logs + Logs @@ -434,16 +518,29 @@ body{font-family:monospace;background:#0d0d0d;color:#d0d0d0;height:100vh;display
- Network - - + Network + +
+