Skip to content

Commit 01596ed

Browse files
feat: add cURL export feature on trace details page
cURL export feature on trace details page
2 parents b294925 + 3a8f425 commit 01596ed

4 files changed

Lines changed: 206 additions & 4 deletions

File tree

DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,44 @@ public void Details_page_renders_waterfall_section_with_ruler_and_tooltips_when_
166166
Assert.Contains("wf-bar--error", html);
167167
}
168168

169+
[Fact]
170+
public void Details_page_renders_curl_copy_attributes_and_buttons()
171+
{
172+
var entry = CreateEntry();
173+
entry.OutgoingRequests.Add(new DebugOutgoingRequest
174+
{
175+
Method = "PUT",
176+
Url = "https://external-api.test/v1/update",
177+
StatusCode = 200,
178+
DurationMs = 120,
179+
RequestBody = "{\"name\":\"John\"}",
180+
RequestHeaders = new Dictionary<string, string> { ["Authorization"] = "Bearer token" }
181+
});
182+
183+
var html = HtmlRenderer.RenderDetailsPage(
184+
entry,
185+
CreateEnvironment(),
186+
"{\"request\":true}",
187+
"{\"response\":true}");
188+
189+
// 1. Verify Incoming Request Card attributes and button
190+
Assert.Contains("data-method=\"POST\"", html);
191+
Assert.Contains("data-url=\"http://example.test/orders?id=10\"", html);
192+
Assert.Contains("data-headers=\"{&quot;X-Test&quot;:&quot;yes&quot;}\"", html);
193+
Assert.Contains("data-body=\"{&quot;request&quot;:true}\"", html);
194+
Assert.Contains("class=\"curl-copy-btn\"", html);
195+
196+
// 2. Verify Outgoing Request Card attributes and button
197+
Assert.Contains("data-method=\"PUT\"", html);
198+
Assert.Contains("data-url=\"https://external-api.test/v1/update\"", html);
199+
Assert.Contains("data-headers=\"{&quot;Authorization&quot;:&quot;Bearer token&quot;}\"", html);
200+
Assert.Contains("data-body=\"{&quot;name&quot;:&quot;John&quot;}\"", html);
201+
202+
// 3. Verify Response Card has no curl copy button (only 2 copy curl buttons in total should exist in HTML markup)
203+
var occurrences = (html.Length - html.Replace("class=\"curl-copy-btn\"", "").Length) / "class=\"curl-copy-btn\"".Length;
204+
Assert.Equal(2, occurrences);
205+
}
206+
169207
private static DebugEntry CreateEntry()
170208
{
171209
return new DebugEntry

DebugProbe.AspNetCore/Assets/css/debugprobe.css

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,3 +1290,52 @@ pre {
12901290
padding-bottom: 4px;
12911291
}
12921292

1293+
/* =========================
1294+
cURL Copy Button & Tooltip
1295+
========================= */
1296+
.curl-copy-btn {
1297+
display: inline-flex;
1298+
align-items: center;
1299+
justify-content: center;
1300+
width: 28px;
1301+
height: 28px;
1302+
padding: 0;
1303+
background: #fff;
1304+
border: 1px solid #e5e7eb;
1305+
border-radius: 6px;
1306+
color: #6b7280;
1307+
cursor: pointer;
1308+
transition: all 0.15s ease;
1309+
}
1310+
1311+
.curl-copy-btn:hover {
1312+
background: #f9fafb;
1313+
border-color: #d1d5db;
1314+
color: #111827;
1315+
}
1316+
1317+
.curl-copy-btn svg {
1318+
width: 14px;
1319+
height: 14px;
1320+
stroke: currentColor;
1321+
stroke-width: 2;
1322+
stroke-linecap: round;
1323+
stroke-linejoin: round;
1324+
fill: none;
1325+
}
1326+
1327+
.copied-tooltip {
1328+
position: absolute;
1329+
background: #1e293b;
1330+
color: #f8fafc;
1331+
padding: 4px 8px;
1332+
border-radius: 4px;
1333+
font-size: 11px;
1334+
font-weight: 600;
1335+
z-index: 1000;
1336+
pointer-events: none;
1337+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
1338+
transform: translate(-50%, -100%);
1339+
margin-top: -6px;
1340+
}
1341+

DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,78 @@ function copyText(btn) {
1010
setTimeout(() => btn.innerText = "Copy", 1500);
1111
}
1212

13+
function buildCurlCommand(method, url, headers, body, isWindows) {
14+
function escapeSingleQuote(str) {
15+
if (!str) return "";
16+
return str.replace(/'/g, "'\\''");
17+
}
18+
19+
function escapeDoubleQuote(str) {
20+
if (!str) return "";
21+
return str.replace(/"/g, '\\"').replace(/%/g, '%%');
22+
}
23+
24+
const quote = isWindows ? '"' : "'";
25+
const escape = isWindows ? escapeDoubleQuote : escapeSingleQuote;
26+
27+
let curlCmd = `curl -X ${method.toUpperCase()} ${quote}${escape(url)}${quote}`;
28+
29+
// Process headers
30+
for (const [key, value] of Object.entries(headers)) {
31+
if (!key || !value) continue;
32+
const trimmedVal = value.trim();
33+
if (trimmedVal === "[REDACTED]" || trimmedVal === "") continue;
34+
35+
curlCmd += ` -H ${quote}${escape(key)}: ${escape(value)}${quote}`;
36+
}
37+
38+
// Process body (skip if empty or truncated indicator)
39+
if (body && body.trim() !== "" && body !== "[Body too large]") {
40+
curlCmd += ` -d ${quote}${escape(body)}${quote}`;
41+
}
42+
43+
return curlCmd;
44+
}
45+
46+
function copyAsCurl(btn) {
47+
const card = btn.closest(".trace-card");
48+
if (!card) return;
49+
50+
const method = card.dataset.method;
51+
const url = card.dataset.url;
52+
if (!method || !url) return;
53+
54+
let headers = {};
55+
try {
56+
headers = JSON.parse(card.dataset.headers || '{}');
57+
} catch (e) {
58+
// Fallback or ignore
59+
}
60+
61+
const body = card.dataset.body;
62+
63+
const isWindows = (navigator.platform && navigator.platform.indexOf('Win') !== -1) ||
64+
(navigator.userAgent && navigator.userAgent.indexOf('Win') !== -1);
65+
66+
const curlCmd = buildCurlCommand(method, url, headers, body, isWindows);
67+
68+
navigator.clipboard.writeText(curlCmd);
69+
70+
// Show temporary "Copied!" tooltip
71+
const tooltip = document.createElement("div");
72+
tooltip.className = "copied-tooltip";
73+
tooltip.textContent = "Copied!";
74+
document.body.appendChild(tooltip);
75+
76+
const rect = btn.getBoundingClientRect();
77+
tooltip.style.left = (rect.left + window.scrollX + rect.width / 2) + "px";
78+
tooltip.style.top = (rect.top + window.scrollY) + "px";
79+
80+
setTimeout(() => {
81+
tooltip.remove();
82+
}, 1500);
83+
}
84+
1385

1486
const clearBtn = document.getElementById("clearBtn");
1587
if (clearBtn) {

DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string
8787
BuildPayloadSection("URL", string.IsNullOrWhiteSpace(x.RequestUrl) ? pathWithQuery : x.RequestUrl, "url"),
8888
BuildHeaderSection("Headers", x.RequestHeaders),
8989
BuildPayloadSection("Body", req, "body")
90-
]);
90+
],
91+
dataMethod: x.Method,
92+
dataUrl: string.IsNullOrWhiteSpace(x.RequestUrl) ? pathWithQuery : x.RequestUrl,
93+
dataHeaders: System.Text.Json.JsonSerializer.Serialize(x.RequestHeaders),
94+
dataBody: x.RequestBody);
9195

9296
var incomingResponse = BuildTraceCard(
9397
"Final Response",
@@ -189,7 +193,11 @@ private static string BuildOutgoingRequestCard(DebugOutgoingRequest request)
189193
statusCode: request.StatusCode,
190194
statusText: request.StatusCode.HasValue ? null : "Failed",
191195
durationMs: request.DurationMs,
192-
details: details);
196+
details: details,
197+
dataMethod: request.Method,
198+
dataUrl: request.Url,
199+
dataHeaders: System.Text.Json.JsonSerializer.Serialize(request.RequestHeaders),
200+
dataBody: request.RequestBody);
193201
}
194202

195203
private static string BuildWaterfallSection(DebugEntry entry)
@@ -280,7 +288,19 @@ private static string BuildWaterfallSection(DebugEntry entry)
280288
</article>";
281289
}
282290

283-
private static string BuildTraceCard(string label, string method, string target, string classes, IEnumerable<string> details, int? statusCode = null, string? statusText = null, long? durationMs = null)
291+
private static string BuildTraceCard(
292+
string label,
293+
string method,
294+
string target,
295+
string classes,
296+
IEnumerable<string> details,
297+
int? statusCode = null,
298+
string? statusText = null,
299+
long? durationMs = null,
300+
string? dataMethod = null,
301+
string? dataUrl = null,
302+
string? dataHeaders = null,
303+
string? dataBody = null)
284304
{
285305
var targetHost = GetDisplayTarget(target);
286306
var status = statusCode.HasValue
@@ -292,8 +312,30 @@ private static string BuildTraceCard(string label, string method, string target,
292312

293313
var methodPill = !string.IsNullOrWhiteSpace(method) ? $@"<span class=""method-pill"">{Encode(method)}</span>" : "";
294314

315+
var dataAttrs = "";
316+
if (!string.IsNullOrWhiteSpace(dataMethod)) dataAttrs += $" data-method=\"{Encode(dataMethod)}\"";
317+
if (!string.IsNullOrWhiteSpace(dataUrl)) dataAttrs += $" data-url=\"{Encode(dataUrl)}\"";
318+
if (!string.IsNullOrWhiteSpace(dataHeaders)) dataAttrs += $" data-headers=\"{Encode(dataHeaders)}\"";
319+
if (!string.IsNullOrWhiteSpace(dataBody)) dataAttrs += $" data-body=\"{Encode(dataBody)}\"";
320+
321+
var copyCurlBtn = "";
322+
if (!string.IsNullOrWhiteSpace(dataMethod))
323+
{
324+
copyCurlBtn = $@"
325+
<button class=""curl-copy-btn""
326+
type=""button""
327+
title=""Copy as cURL""
328+
aria-label=""Copy as cURL""
329+
onclick=""copyAsCurl(this)"">
330+
<svg viewBox=""0 0 24 24"" aria-hidden=""true"">
331+
<path d=""M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2""></path>
332+
<rect x=""8"" y=""2"" width=""8"" height=""4"" rx=""1"" ry=""1""></rect>
333+
</svg>
334+
</button>";
335+
}
336+
295337
return $@"
296-
<article class=""trace-card {Encode(classes)}"">
338+
<article class=""trace-card {Encode(classes)}""{dataAttrs}>
297339
<div class=""trace-card-main"">
298340
<div class=""trace-card-header"">
299341
<div class=""trace-card-title"">
@@ -305,6 +347,7 @@ private static string BuildTraceCard(string label, string method, string target,
305347
<div class=""trace-card-meta"">
306348
{status}
307349
{duration}
350+
{copyCurlBtn}
308351
</div>
309352
</div>
310353
<div class=""trace-details"">

0 commit comments

Comments
 (0)