Skip to content

Commit 2302d30

Browse files
Feature/waterfall view phase2
Waterfall/Timeline View — Phase 2
2 parents 1d8994b + 967b510 commit 2302d30

4 files changed

Lines changed: 243 additions & 2 deletions

File tree

DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,61 @@ public void Html_encoding_escapes_untrusted_values()
111111
Assert.Contains("/orders/<bad>", html);
112112
}
113113

114+
[Fact]
115+
public void Details_page_renders_waterfall_section_with_ruler_and_tooltips_when_outgoing_requests_exist()
116+
{
117+
var entry = CreateEntry();
118+
entry.DurationMs = 500;
119+
120+
entry.OutgoingRequests.Add(new DebugOutgoingRequest
121+
{
122+
Method = "GET",
123+
Url = "https://external-api.test/v1/users?id=123",
124+
StatusCode = 200,
125+
DurationMs = 150,
126+
TimestampUtc = entry.Timestamp.UtcDateTime.AddMilliseconds(200), // starts at 50ms offset (200 - 150)
127+
IsSuccessStatusCode = true
128+
});
129+
130+
entry.OutgoingRequests.Add(new DebugOutgoingRequest
131+
{
132+
Method = "POST",
133+
Url = "https://untrusted-api.test/v1/search?query=a&b=%3Cscript%3E",
134+
StatusCode = 500,
135+
DurationMs = 80,
136+
TimestampUtc = entry.Timestamp.UtcDateTime.AddMilliseconds(400), // starts at 320ms offset (400 - 80)
137+
IsSuccessStatusCode = false
138+
});
139+
140+
var html = HtmlRenderer.RenderDetailsPage(
141+
entry,
142+
CreateEnvironment(),
143+
"{}",
144+
"{}");
145+
146+
// Verify Ruler ticks exist
147+
Assert.Contains("waterfall-ruler-row", html);
148+
Assert.Contains("wf-ruler-ticks", html);
149+
Assert.Contains("0 ms", html);
150+
Assert.Contains("125 ms", html);
151+
Assert.Contains("250 ms", html);
152+
Assert.Contains("375 ms", html);
153+
Assert.Contains("500 ms", html);
154+
155+
// Verify first request attributes (success)
156+
Assert.Contains("data-wf-start=\"50\"", html);
157+
Assert.Contains("data-wf-duration=\"150\"", html);
158+
Assert.Contains("data-wf-url=\"external-api.test/v1/users?id=123\"", html);
159+
Assert.Contains("data-wf-status=\"200\"", html);
160+
161+
// Verify second request attributes (failure & html-encoded)
162+
Assert.Contains("data-wf-start=\"320\"", html);
163+
Assert.Contains("data-wf-duration=\"80\"", html);
164+
Assert.Contains("data-wf-url=\"untrusted-api.test/v1/search?query=a&b=%3Cscript%3E\"", html);
165+
Assert.Contains("data-wf-status=\"500\"", html);
166+
Assert.Contains("wf-bar--error", html);
167+
}
168+
114169
private static DebugEntry CreateEntry()
115170
{
116171
return new DebugEntry

DebugProbe.AspNetCore/Assets/css/debugprobe.css

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,3 +1211,82 @@ pre {
12111211
}
12121212
}
12131213

1214+
/* Phase 2: Ruler, Gridlines & Tooltip styles */
1215+
.waterfall-ruler-row {
1216+
display: flex;
1217+
align-items: center;
1218+
gap: 12px;
1219+
margin-bottom: 8px;
1220+
border-bottom: 1px solid #e5e7eb;
1221+
padding-bottom: 4px;
1222+
}
1223+
1224+
.wf-ruler-label-placeholder {
1225+
flex: 0 0 200px;
1226+
width: 200px;
1227+
min-width: 200px;
1228+
}
1229+
1230+
@media (max-width: 640px) {
1231+
.wf-ruler-label-placeholder {
1232+
flex: 0 0 100px;
1233+
width: 100px;
1234+
min-width: 100px;
1235+
}
1236+
}
1237+
1238+
.wf-ruler-ticks {
1239+
flex: 1;
1240+
position: relative;
1241+
height: 18px;
1242+
}
1243+
1244+
.wf-ruler-tick {
1245+
position: absolute;
1246+
top: 0;
1247+
transform: translateX(-50%);
1248+
font-size: 10px;
1249+
color: #6b7280;
1250+
font-weight: bold;
1251+
white-space: nowrap;
1252+
}
1253+
1254+
.wf-ruler-tick:first-child {
1255+
transform: none;
1256+
}
1257+
1258+
.wf-ruler-tick:last-child {
1259+
transform: translateX(-100%);
1260+
}
1261+
1262+
.wf-track {
1263+
background-image: linear-gradient(to right, rgba(0, 0, 0, 0.04) 1px, transparent 1px);
1264+
background-size: 25% 100%;
1265+
}
1266+
1267+
.wf-tooltip {
1268+
position: absolute;
1269+
background: #1e293b;
1270+
color: #f8fafc;
1271+
padding: 8px 12px;
1272+
border-radius: 6px;
1273+
font-size: 11px;
1274+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1275+
pointer-events: none;
1276+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.15), 0 2px 4px -2px rgba(0, 0, 0, 0.15);
1277+
z-index: 1000;
1278+
line-height: 1.4;
1279+
max-width: 300px;
1280+
word-break: break-all;
1281+
display: none;
1282+
border: 1px solid #334155;
1283+
}
1284+
1285+
.wf-tooltip-url {
1286+
font-weight: bold;
1287+
margin-bottom: 4px;
1288+
color: #38bdf8;
1289+
border-bottom: 1px solid #475569;
1290+
padding-bottom: 4px;
1291+
}
1292+

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

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
function copyText(btn) {
1+
function copyText(btn) {
22
const pre = btn.closest(".code-block").querySelector("pre");
33

44
const text = pre.dataset.copy ?? pre.innerText;
@@ -69,3 +69,83 @@ resetFiltersBtn?.addEventListener("click", () => {
6969
applyRequestFilters();
7070
requestSearch?.focus();
7171
});
72+
73+
// Phase 2: Waterfall Timeline Interactivity
74+
document.addEventListener("DOMContentLoaded", () => {
75+
let tooltip = document.getElementById("wfTooltip");
76+
if (!tooltip) {
77+
tooltip = document.createElement("div");
78+
tooltip.id = "wfTooltip";
79+
tooltip.className = "wf-tooltip";
80+
document.body.appendChild(tooltip);
81+
}
82+
83+
const bars = document.querySelectorAll(".wf-bar");
84+
bars.forEach(bar => {
85+
bar.addEventListener("mouseenter", () => {
86+
const start = bar.getAttribute("data-wf-start");
87+
const duration = bar.getAttribute("data-wf-duration");
88+
const url = bar.getAttribute("data-wf-url");
89+
const status = bar.getAttribute("data-wf-status") || "N/A";
90+
const isError = bar.classList.contains("wf-bar--error");
91+
92+
tooltip.innerHTML = "";
93+
94+
const urlEl = document.createElement("div");
95+
urlEl.className = "wf-tooltip-url";
96+
urlEl.textContent = url;
97+
98+
const startEl = document.createElement("div");
99+
startEl.innerHTML = "<strong>Start:</strong> +" + escapeHtml(start) + " ms";
100+
101+
const durationEl = document.createElement("div");
102+
durationEl.innerHTML = "<strong>Duration:</strong> " + escapeHtml(duration) + " ms";
103+
104+
const statusEl = document.createElement("div");
105+
const statusSpan = document.createElement("span");
106+
statusSpan.style.color = isError ? "#f87171" : "#4ade80";
107+
statusSpan.style.fontWeight = "bold";
108+
statusSpan.textContent = status;
109+
statusEl.innerHTML = "<strong>Status:</strong> ";
110+
statusEl.appendChild(statusSpan);
111+
112+
tooltip.appendChild(urlEl);
113+
tooltip.appendChild(startEl);
114+
tooltip.appendChild(durationEl);
115+
tooltip.appendChild(statusEl);
116+
117+
tooltip.style.display = "block";
118+
});
119+
120+
bar.addEventListener("mousemove", (e) => {
121+
const tooltipWidth = tooltip.offsetWidth;
122+
const tooltipHeight = tooltip.offsetHeight;
123+
let left = e.pageX + 10;
124+
let top = e.pageY + 10;
125+
126+
if (left + tooltipWidth > window.innerWidth + window.pageXOffset) {
127+
left = e.pageX - tooltipWidth - 10;
128+
}
129+
if (top + tooltipHeight > window.innerHeight + window.pageYOffset) {
130+
top = e.pageY - tooltipHeight - 10;
131+
}
132+
133+
tooltip.style.left = left + "px";
134+
tooltip.style.top = top + "px";
135+
});
136+
137+
bar.addEventListener("mouseleave", () => {
138+
tooltip.style.display = "none";
139+
});
140+
});
141+
142+
function escapeHtml(str) {
143+
if (!str) return "";
144+
return str
145+
.replace(/&/g, "&amp;")
146+
.replace(/</g, "&lt;")
147+
.replace(/>/g, "&gt;")
148+
.replace(/"/g, "&quot;")
149+
.replace(/'/g, "&#039;");
150+
}
151+
});

DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,23 @@ private static string BuildWaterfallSection(DebugEntry entry)
208208
totalSpan = 1.0;
209209
}
210210

211+
var ticksHtml = new List<string>();
212+
for (int i = 0; i <= 4; i++)
213+
{
214+
var tickVal = (totalSpan * i) / 4.0;
215+
var tickLeft = i * 25;
216+
var tickValStr = tickVal.ToString("0", System.Globalization.CultureInfo.InvariantCulture);
217+
ticksHtml.Add($@"<div class=""wf-ruler-tick"" style=""left: {tickLeft}%;"">{tickValStr} ms</div>");
218+
}
219+
220+
var rulerHtml = $@"
221+
<div class=""waterfall-ruler-row"">
222+
<div class=""wf-ruler-label-placeholder""></div>
223+
<div class=""wf-ruler-ticks"">
224+
{string.Join("", ticksHtml)}
225+
</div>
226+
</div>";
227+
211228
var rowsHtml = new List<string>();
212229

213230
foreach (var outgoing in entry.OutgoingRequests)
@@ -228,11 +245,20 @@ private static string BuildWaterfallSection(DebugEntry entry)
228245

229246
var displayLabel = GetDisplayTarget(outgoing.Url);
230247

248+
var dataStart = Encode(Math.Max(0, startOffsetMs).ToString("0", System.Globalization.CultureInfo.InvariantCulture));
249+
var dataDuration = Encode(outgoing.DurationMs.ToString(System.Globalization.CultureInfo.InvariantCulture));
250+
var dataUrl = Encode(displayLabel);
251+
var dataStatus = Encode(outgoing.StatusCode.HasValue ? outgoing.StatusCode.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "Failed");
252+
231253
rowsHtml.Add($@"
232254
<div class=""waterfall-row"">
233255
<span class=""wf-label"" title=""{Encode(outgoing.Url)}"">{Encode(displayLabel)}</span>
234256
<div class=""wf-track"">
235-
<div class=""{barClass}"" style=""left: {leftStr}%; width: {widthStr}%;"">{outgoing.DurationMs} ms</div>
257+
<div class=""{barClass}"" style=""left: {leftStr}%; width: {widthStr}%;""
258+
data-wf-start=""{dataStart}""
259+
data-wf-duration=""{dataDuration}""
260+
data-wf-url=""{dataUrl}""
261+
data-wf-status=""{dataStatus}"">{outgoing.DurationMs} ms</div>
236262
</div>
237263
</div>");
238264
}
@@ -247,6 +273,7 @@ private static string BuildWaterfallSection(DebugEntry entry)
247273
</div>
248274
</div>
249275
<div class=""trace-details"">
276+
{rulerHtml}
250277
{string.Join("", rowsHtml)}
251278
</div>
252279
</div>

0 commit comments

Comments
 (0)