Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions internal/gui/bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,11 @@ func captureSummary(rows []store.TrafficCaptureRow) []CaptureView {
return out
}

// captureDetail converts one capture row into the full DTO. Text fields remain
// for compatibility and convenient inspection; Base64 fields preserve exact
// bytes for binary viewers and downloads.
// captureDetail converts one capture row into the full DTO. Bodies travel as
// Base64 only: shipping the same bytes as UTF-8 as well doubled every
// response (a 5 MB body became 12+ MB of JSON), and the webview's JSON.parse
// of that payload was a large part of the traffic-tab freezes. The body
// viewer already decodes Base64 byte-exactly.
func captureDetail(c *store.TrafficCaptureRow) CaptureView {
return CaptureView{
ID: c.ID,
Expand All @@ -452,11 +454,9 @@ func captureDetail(c *store.TrafficCaptureRow) CaptureView {
URL: c.URL,
Status: c.Status,
ReqHeaders: parseHeaders(c.ReqHeadersJSON),
ReqBody: string(c.ReqBody),
ReqBodyBase64: base64.StdEncoding.EncodeToString(c.ReqBody),
ReqBodyLen: len(c.ReqBody),
RespHeaders: parseHeaders(c.RespHeadersJSON),
RespBody: string(c.RespBody),
RespBodyBase64: base64.StdEncoding.EncodeToString(c.RespBody),
RespBodyLen: len(c.RespBody),
}
Expand Down
49 changes: 40 additions & 9 deletions ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ function CommandDeck({ activeTab, onChange, counts, onSearch, project, filtered,
</div>`;
}

// Row fingerprints. Every poll calls setState with a fresh array identity,
// which re-renders the whole tree — including any open detail pane, whose
// body viewer is expensive on large payloads. Returning the previous state
// identity when the rows did not change lets Preact skip that re-render.
// Webhook (project, seq) pairs and capture ids are immutable once stored,
// so these fingerprints cannot miss a real change.
const webhooksFingerprint = (rows) => rows.map((w) => `${w.project}/${w.seq}`).join("|");
const capturesFingerprint = (rows) => rows.map((c) => `${c.id}:${c.status}`).join("|");
const sessionsFingerprint = (rows) => rows.map((s) => `${s.id}:${s.captures}:${s.ended_at || ""}`).join("|");

function App() {
const [activeTab, setActiveTab] = useState("webhooks");
const [status, setStatus] = useState(null);
Expand Down Expand Up @@ -92,21 +102,24 @@ function App() {
};
const loadWebhooks = async () => {
try {
setWebhooks((await api.listWebhooks(project)) || []);
const rows = (await api.listWebhooks(project)) || [];
setWebhooks((prev) => (webhooksFingerprint(prev) === webhooksFingerprint(rows) ? prev : rows));
} catch (e) {
showToast("list webhooks: " + e);
}
};
const loadCaptures = async () => {
try {
setCaptures((await api.listCaptures(sessionFilter)) || []);
const rows = (await api.listCaptures(sessionFilter)) || [];
setCaptures((prev) => (capturesFingerprint(prev) === capturesFingerprint(rows) ? prev : rows));
} catch (e) {
showToast("list captures: " + e);
}
};
const loadSessions = async () => {
try {
setSessions((await api.listSessions()) || []);
const rows = (await api.listSessions()) || [];
setSessions((prev) => (sessionsFingerprint(prev) === sessionsFingerprint(rows) ? prev : rows));
} catch (e) {
showToast("list sessions: " + e);
}
Expand Down Expand Up @@ -173,20 +186,38 @@ function App() {
}, [webhooks, captures, activeTab, follow]);

// --- selection handlers ------------------------------------------------
// Rapid row clicking must not pile work onto the webview: each detail
// fetch ships the full body payload and each render decodes it, so every
// queued click used to add another multi-MB render on arrival. In-flight
// requests are deduplicated per row, and responses that were superseded
// by a newer click are dropped instead of rendered.
const latestDetailReq = useRef(0);
const inflightDetails = useRef(new Map());
const fetchDetail = (key, load) => {
const pending = inflightDetails.current;
let p = pending.get(key);
if (!p) {
p = load().finally(() => pending.delete(key));
pending.set(key, p);
}
return p;
};
const openWebhook = async (proj, seq) => {
const req = ++latestDetailReq.current;
try {
const w = await api.getWebhook(proj, seq);
setSelection({ kind: "webhook", data: w });
const w = await fetchDetail(`${proj}/${seq}`, () => api.getWebhook(proj, seq));
if (req === latestDetailReq.current) setSelection({ kind: "webhook", data: w });
} catch (e) {
showToast("get webhook: " + e);
if (req === latestDetailReq.current) showToast("get webhook: " + e);
}
};
const openCapture = async (id) => {
const req = ++latestDetailReq.current;
try {
const c = await api.getCapture(id);
setSelection({ kind: "traffic", data: c });
const c = await fetchDetail(`c/${id}`, () => api.getCapture(id));
if (req === latestDetailReq.current) setSelection({ kind: "traffic", data: c });
} catch (e) {
showToast("get capture: " + e);
if (req === latestDetailReq.current) showToast("get capture: " + e);
}
};
const openScript = async (s) => {
Expand Down
112 changes: 96 additions & 16 deletions ui/components/code-block.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
// BodyViewer inspects captured request/response bodies without assuming they are
// UTF-8. Auto follows Content-Type; explicit modes handle incorrect headers.
//
// Bodies are rendered through a window: only the first DISPLAY_LIMIT bytes are
// decoded and put into the DOM, regardless of payload size. Materializing a
// multi-megabyte body as DOM text cost 0.5–2s of blocked webview main thread
// per open (and piled up fatally under rapid row switching), while the full
// bytes remain available for Copy, Save, Image, and an explicit
// "show entire body" escalation.
import { html } from "../vendor/preact/index.js";
import { useEffect, useMemo, useState } from "../vendor/preact/index.js";
import { prettyBody, highlightJSON, escapeHTML } from "../lib/format.js";
import { copyText } from "../lib/clipboard.js";
import { Dropdown } from "./dropdown.js";

// How much of a body is decoded and rendered up front. Above this the viewer
// shows a slice plus an escalation button. 256 KiB keeps decode + escape +
// DOM work in the tens of milliseconds even when the payload is megabytes.
const DISPLAY_LIMIT = 256 * 1024;
// Pretty-print and JSON highlighting are only attempted below this size.
// Both are synchronous O(body) passes that build several multiples of the
// body size in strings and DOM.
const PRETTY_LIMIT = 1024 * 1024;
// Preview is refused outright above this size; Save still works.
const LARGE_BODY_LIMIT = 15 * 1024 * 1024;

function mediaType(contentType) {
Expand All @@ -27,6 +43,13 @@ function decodeBase64(encoded) {
return bytes;
}

// sliceBase64 cuts a byte-prefix of a base64 string. Only whole 4-char
// blocks decode to bytes, so the cut is aligned down to one.
function sliceBase64(encoded, maxBytes) {
const chars = Math.floor(maxBytes / 3) * 4;
return encoded.length <= chars ? encoded : encoded.slice(0, chars);
}

function bytesToText(bytes) {
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
Expand All @@ -42,34 +65,82 @@ function hexDump(bytes) {
return lines.join("\n");
}

function fmtMB(n) {
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}

export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeightClass = "max-h-72" }) {
const [mode, setMode] = useState("auto");
const [copyState, setCopyState] = useState("idle");
const [showAll, setShowAll] = useState(false);
const type = mediaType(contentType);
const bytes = useMemo(() => bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || ""), [body, bodyBase64]);
const effectiveMode = mode === "auto" ? autoMode(type) : mode;

const totalLength = bodyLength || (bodyBase64 ? Math.floor(bodyBase64.length * 3 / 4) : (body || "").length);
const truncated = !showAll && totalLength > DISPLAY_LIMIT;

// A new body resets the escalation so a big row never opens expanded.
useEffect(() => {
setShowAll(false);
}, [bodyBase64, body]);

// The display window: at most DISPLAY_LIMIT bytes are ever decoded for
// rendering. Copy, Save, and Image decode the full payload on demand.
const displayB64 = useMemo(
() => (bodyBase64 && truncated ? sliceBase64(bodyBase64, DISPLAY_LIMIT) : bodyBase64 || ""),
[bodyBase64, truncated],
);
const displayText = useMemo(
() => (!bodyBase64 && body ? (truncated ? body.slice(0, DISPLAY_LIMIT) : body) : ""),
[body, bodyBase64, truncated],
);
const bytes = useMemo(
() => (displayB64 ? decodeBase64(displayB64) : new TextEncoder().encode(displayText || "")),
[displayB64, displayText],
);
const text = useMemo(() => bytesToText(bytes), [bytes]);
const isLarge = (bodyLength || bytes.length) > LARGE_BODY_LIMIT;
const isLarge = totalLength > LARGE_BODY_LIMIT;
const imageURL = useMemo(() => {
if (effectiveMode !== "image" || !bytes.length || !/^image\//.test(type)) return "";
return URL.createObjectURL(new Blob([bytes], { type: type || "application/octet-stream" }));
}, [bytes, effectiveMode, type]);
if (effectiveMode !== "image" || !totalLength || !/^image\//.test(type)) return "";
// Images need the complete bytes, however large; they are the one mode
// that cannot render from a prefix.
const full = bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || "");
if (!full.length) return "";
return URL.createObjectURL(new Blob([full], { type: type || "application/octet-stream" }));
}, [body, bodyBase64, effectiveMode, type, totalLength]);
useEffect(() => () => { if (imageURL) URL.revokeObjectURL(imageURL); }, [imageURL]);

if (!bytes.length) return html`<p class="body-empty">(empty)</p>`;
if (!totalLength && !bytes.length) return html`<p class="body-empty">(empty)</p>`;

// Heavy transforms are memoized and only attempted within the display
// window; the poll loop re-renders this component every cycle for free.
const oversizePretty = totalLength > PRETTY_LIMIT;
const pretty = useMemo(() => {
if (effectiveMode !== "pretty" || oversizePretty || truncated) return { text, isJSON: false };
return prettyBody(text, contentType);
}, [text, contentType, effectiveMode, oversizePretty, truncated]);
const renderedText = pretty.isJSON ? pretty.text : text;
const inner = useMemo(() => {
if (pretty.isJSON) return highlightJSON(renderedText);
return escapeHTML(effectiveMode === "hex" ? hexDump(bytes) : renderedText);
}, [pretty, renderedText, effectiveMode, bytes]);
// Stable object identity so Preact does not re-assign innerHTML when the
// content is unchanged.
const innerHTML = useMemo(() => ({ __html: inner }), [inner]);

const pretty = prettyBody(text, contentType);
const renderedText = effectiveMode === "pretty" && pretty.isJSON ? pretty.text : text;
const inner = effectiveMode === "pretty" && pretty.isJSON
? highlightJSON(renderedText)
: escapeHTML(effectiveMode === "hex" ? hexDump(bytes) : renderedText);
const fullText = () => (bodyBase64 ? bytesToText(decodeBase64(bodyBase64)) : (body || ""));
const fullBytes = () => (bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || ""));
const copy = async () => {
try { await copyText(renderedText); setCopyState("copied"); }
catch { setCopyState("failed"); }
try {
await copyText(fullText());
setCopyState("copied");
} catch {
setCopyState("failed");
}
setTimeout(() => setCopyState("idle"), 1600);
};
const download = () => {
const url = URL.createObjectURL(new Blob([bytes], { type: type || "application/octet-stream" }));
const url = URL.createObjectURL(new Blob([fullBytes()], { type: type || "application/octet-stream" }));
const a = document.createElement("a");
a.href = url;
a.download = `wiretap-body.${type.split("/")[1]?.split("+")[0] || "bin"}`;
Expand Down Expand Up @@ -97,11 +168,20 @@ export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeigh
<button class="body-action" onClick=${download}>Save</button>
${effectiveMode !== "image" ? html`<button class="body-action" onClick=${copy}>${copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy"}</button>` : null}
</div>
${truncated
? html`<div class="body-large-warning">
Showing the first ${Math.round(DISPLAY_LIMIT / 1024)} KB of ${fmtMB(totalLength)}.
<button class="body-action" onClick=${() => setShowAll(true)}>Show entire body</button>
(slower for large bodies) — Copy and Save always use the full payload.
</div>`
: oversizePretty && effectiveMode === "pretty"
? html`<div class="body-large-warning">Body is over 1 MB — pretty-print and highlighting are skipped for responsiveness. Use Text, Hex, or Save.</div>`
: null}
${isLarge
? html`<div class="body-large-warning">This body is ${Math.round((bodyLength || bytes.length) / 1024 / 1024)} MB. Previewing it may use significant memory. Save the original to inspect it externally.</div>`
? html`<div class="body-large-warning">This body is ${fmtMB(totalLength)}. Previewing it may use significant memory. Save the original to inspect it externally.</div>`
: effectiveMode === "image" && imageURL
? html`<div class="body-image-wrap"><img class="body-image" src=${imageURL} alt="Captured ${type} body" /></div>`
: html`<pre class="${maxHeightClass} body-code" dangerouslySetInnerHTML=${{ __html: inner }}></pre>`}
: html`<pre class="${maxHeightClass} body-code" dangerouslySetInnerHTML=${innerHTML}></pre>`}
</div>`;
}

Expand Down
30 changes: 20 additions & 10 deletions ui/components/export-snippet.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,27 @@ export function ExportSnippet({ exportKey, convert }) {
[targets, target],
);

// Re-convert whenever the row or the language/client selection changes.
// Re-convert whenever the row or the language/client selection changes —
// debounced, because conversion spins a JS runtime on the Go side per
// call. Clicking rapidly through capture rows otherwise fires one
// conversion per click, and the pile-up froze the UI.
useEffect(() => {
if (!active) return undefined;
let alive = true;
setError("");
convert(target, client).then(
(out) => alive && setSnippet(out),
(e) => {
if (!alive) return;
setSnippet("");
setError(String(e));
},
);
const timer = setTimeout(() => {
convert(target, client).then(
(out) => alive && setSnippet(out),
(e) => {
if (!alive) return;
setSnippet("");
setError(String(e));
},
);
}, 250);
return () => {
alive = false;
clearTimeout(timer);
};
}, [exportKey, target, client, active]);

Expand Down Expand Up @@ -97,6 +103,10 @@ export function ExportSnippet({ exportKey, convert }) {
]
: [{ value: "", label: "default" }];

// Stable object identity so the snippet <pre> is not re-assigned
// innerHTML on unrelated re-renders (the poll loop).
const snippetHTML = useMemo(() => ({ __html: escapeHTML(snippet) }), [snippet]);

return html`<section class="inspector-section border-t border-neutral-800 pt-4">
<div class="inspector-label">Export as code</div>
<div class="flex gap-2">
Expand Down Expand Up @@ -129,7 +139,7 @@ export function ExportSnippet({ exportKey, convert }) {
</div>
<pre
class="max-h-72 overflow-auto p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words"
dangerouslySetInnerHTML=${{ __html: escapeHTML(snippet) }}
dangerouslySetInnerHTML=${snippetHTML}
></pre>
</div>`}
</section>`;
Expand Down
2 changes: 0 additions & 2 deletions ui/components/traffic-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export function TrafficDetail({ capture, onExport, onClose }) {
len=${fmtBytes(capture.req_body_len)}
>
<${CodeBlock}
body=${capture.req_body}
bodyBase64=${capture.req_body_base64}
bodyLength=${capture.req_body_len}
contentType=${reqCT}
Expand All @@ -63,7 +62,6 @@ export function TrafficDetail({ capture, onExport, onClose }) {
len=${fmtBytes(capture.resp_body_len)}
>
<${CodeBlock}
body=${capture.resp_body}
bodyBase64=${capture.resp_body_base64}
bodyLength=${capture.resp_body_len}
contentType=${respCT}
Expand Down
6 changes: 2 additions & 4 deletions ui/lib/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,12 @@ export function prettyBody(body, contentType) {

/**
* Escape HTML-special characters so a string is safe to inject as innerHTML.
* Single pass: bodies run through this on every display window change.
* @param {string} s
* @returns {string}
*/
export function escapeHTML(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return String(s).replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}

/**
Expand Down