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
80 changes: 80 additions & 0 deletions DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,4 +523,84 @@ public void Render_details_page_outgoing_call_boundary_minus_one()
Assert.Contains("999 ms</div>", html);
Assert.DoesNotContain(" 999 ms <span class=\"dbp-badge dbp-badge-slow\"", html);
}

[Fact]
public void Build_request_rate_sparkline_handles_empty_state_gracefully()
{
var options = new DebugProbeOptions { LookbackMinutes = 15 };
var html = HtmlRenderer.BuildRequestRateSparkline(new List<DebugEntry>(), options);

Assert.Contains("class=\"dbp-sparkline\"", html);
// Should contain flat line point coordinates
Assert.Contains("22", html);
}

[Fact]
public void Build_request_rate_sparkline_maps_buckets_correctly()
{
var options = new DebugProbeOptions { LookbackMinutes = 15 };
var now = DateTimeOffset.UtcNow;
var entries = new List<DebugEntry>
{
new DebugEntry { Timestamp = now.AddMinutes(-1), StatusCode = 200, RequestBody = "", ResponseBody = "" },
new DebugEntry { Timestamp = now.AddMinutes(-5), StatusCode = 200, RequestBody = "", ResponseBody = "" }
};

var html = HtmlRenderer.BuildRequestRateSparkline(entries, options);
Assert.Contains("class=\"dbp-sparkline\"", html);
}

[Fact]
public void Compute_error_rate_trend_suppresses_arrow_when_previous_window_empty()
{
var options = new DebugProbeOptions { LookbackMinutes = 15 };
var now = DateTimeOffset.UtcNow;
var entries = new List<DebugEntry>
{
new DebugEntry { Timestamp = now.AddMinutes(-1), StatusCode = 500, RequestBody = "", ResponseBody = "" }
};

var trend = HtmlRenderer.ComputeErrorRateTrend(entries, options);
Assert.Equal(string.Empty, trend);
}

[Fact]
public void Compute_error_rate_trend_indicates_worse_when_error_rate_increases()
{
var options = new DebugProbeOptions { LookbackMinutes = 15 };
var now = DateTimeOffset.UtcNow;
var entries = new List<DebugEntry>
{
// Previous window: 2 requests, 0 errors => 0% error rate
new DebugEntry { Timestamp = now.AddMinutes(-20), StatusCode = 200, RequestBody = "", ResponseBody = "" },
new DebugEntry { Timestamp = now.AddMinutes(-25), StatusCode = 200, RequestBody = "", ResponseBody = "" },
// Current window: 2 requests, 1 error => 50% error rate
new DebugEntry { Timestamp = now.AddMinutes(-2), StatusCode = 500, RequestBody = "", ResponseBody = "" },
new DebugEntry { Timestamp = now.AddMinutes(-5), StatusCode = 200, RequestBody = "", ResponseBody = "" }
};

var trend = HtmlRenderer.ComputeErrorRateTrend(entries, options);
Assert.Contains("dbp-trend--worse", trend);
Assert.Contains("\u2191", trend);
}

[Fact]
public void Compute_error_rate_trend_indicates_better_when_error_rate_decreases()
{
var options = new DebugProbeOptions { LookbackMinutes = 15 };
var now = DateTimeOffset.UtcNow;
var entries = new List<DebugEntry>
{
// Previous window: 2 requests, 1 error => 50% error rate
new DebugEntry { Timestamp = now.AddMinutes(-20), StatusCode = 500, RequestBody = "", ResponseBody = "" },
new DebugEntry { Timestamp = now.AddMinutes(-25), StatusCode = 200, RequestBody = "", ResponseBody = "" },
// Current window: 2 requests, 0 errors => 0% error rate
new DebugEntry { Timestamp = now.AddMinutes(-2), StatusCode = 200, RequestBody = "", ResponseBody = "" },
new DebugEntry { Timestamp = now.AddMinutes(-5), StatusCode = 200, RequestBody = "", ResponseBody = "" }
};

var trend = HtmlRenderer.ComputeErrorRateTrend(entries, options);
Assert.Contains("dbp-trend--better", trend);
Assert.Contains("\u2193", trend);
}
}
60 changes: 60 additions & 0 deletions DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,3 +471,63 @@ document.addEventListener("DOMContentLoaded", () => {
.replace(/'/g, "&#039;");
}
});

// Global keyboard shortcuts listener for the DebugProbe dashboard.
document.addEventListener("keydown", (e) => {
// Ignore keydown when a modifier key is held (Ctrl/Cmd/Alt)
if (e.ctrlKey || e.metaKey || e.altKey) {
return;
}

// Ignore repeating events from holding down keys
if (e.repeat) {
return;
}

const key = e.key;

// "Escape" must always work, even inside input/textarea/contenteditable elements
if (key === "Escape" || key === "Esc") {
const backLink = document.querySelector('a[href="/debug"]') ||
Array.from(document.querySelectorAll("a")).find(a => a.textContent.includes("Back"));
if (backLink) {
backLink.click();
} else {
// If on the dashboard and in the search box, Esc blurs the input
const activeEl = document.activeElement;
if (activeEl && typeof activeEl.blur === "function") {
activeEl.blur();
}
}
return;
}

// Guard rail: skip all other shortcuts when in input/textarea/contenteditable
const activeEl = document.activeElement;
if (activeEl) {
const tag = activeEl.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || activeEl.isContentEditable) {
return;
}
}

switch (key) {
case "/": {
const searchInput = document.getElementById("requestSearch");
if (searchInput) {
e.preventDefault();
searchInput.focus();
}
break;
}
case "c":
case "C": {
const curlBtn = document.querySelector(".trace-card.request .curl-copy-btn");
if (curlBtn) {
curlBtn.click();
}
break;
}
}
});

112 changes: 110 additions & 2 deletions DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ public static string RenderIndexPage(List<DebugEntry> items, DebugProbeOptions?
</div>";
}

var sparklineHtml = BuildRequestRateSparkline(items, options);
var errorRateTrendHtml = ComputeErrorRateTrend(items, options);

var pageHtml = EmbeddedResources.Index;
if (!string.IsNullOrEmpty(exceptionPanel))
{
Expand All @@ -113,10 +116,10 @@ public static string RenderIndexPage(List<DebugEntry> items, DebugProbeOptions?
.Replace("{{rows}}", rows)
.Replace("{{total_count}}", items.Count.ToString())
.Replace("{{method_options}}", methodOptions)
.Replace("{{total_requests}}", FormatCompactNumber(totalRequests))
.Replace("{{total_requests}}", FormatCompactNumber(totalRequests) + sparklineHtml)
.Replace("{{avg_response_time}}", $"{averageResponseMs} ms")
.Replace("{{slow_requests}}", FormatCompactNumber(slowRequests))
.Replace("{{error_rate}}", $"{errorRate:0.#}%"));
.Replace("{{error_rate}}", $"{errorRate:0.#}%" + errorRateTrendHtml));
}

public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string req, string res, DebugProbeOptions? options = null)
Expand Down Expand Up @@ -560,4 +563,109 @@ private static string RenderSlowBadge(TimeSpan duration, DebugProbeOptions optio
return string.Empty;
}

/// <summary>
/// Builds an SVG sparkline showing request rate over the configured lookback window.
/// </summary>
internal static string BuildRequestRateSparkline(List<DebugEntry> items, DebugProbeOptions options)
{
var lookback = options.LookbackMinutes;
if (lookback <= 0)
{
return string.Empty;
}

var now = DateTime.UtcNow;
var buckets = new int[lookback];

foreach (var entry in items)
{
var diff = now - entry.Timestamp.UtcDateTime;
var minutesAgo = (int)diff.TotalMinutes;
if (minutesAgo < 0) minutesAgo = 0;
if (minutesAgo < lookback)
{
buckets[lookback - 1 - minutesAgo]++;
}
}

const int w = 120;
const int h = 24;
var maxCount = buckets.Max();

var points = new List<string>();
for (int i = 0; i < lookback; i++)
{
double x = lookback > 1 ? (double)i / (lookback - 1) * w : 0;
double y = maxCount == 0 ? h - 2 : h - 2 - ((double)buckets[i] / maxCount * (h - 4));
points.Add($"{x:0.#},{y:0.#}");
}

var pointsStr = string.Join(" ", points);

return $@"<svg width=""{w}"" height=""{h}"" viewBox=""0 0 {w} {h}"" class=""dbp-sparkline"" style=""display: inline-block; vertical-align: middle; margin-left: 8px;"" aria-label=""Request rate sparkline""><polyline fill=""none"" stroke=""#6c5ce7"" stroke-width=""2"" stroke-linecap=""round"" stroke-linejoin=""round"" points=""{pointsStr}""/></svg>";
}

/// <summary>
/// Computes error rate trends comparing the current window to the previous window.
/// </summary>
internal static string ComputeErrorRateTrend(List<DebugEntry> items, DebugProbeOptions options)
{
var lookback = options.LookbackMinutes;
if (lookback <= 0)
{
return string.Empty;
}

var now = DateTime.UtcNow;
var currentStart = now.AddMinutes(-lookback);
var previousStart = now.AddMinutes(-2 * lookback);

var currentCount = 0;
var currentErrors = 0;
var previousCount = 0;
var previousErrors = 0;

foreach (var entry in items)
{
var entryTime = entry.Timestamp.UtcDateTime;
if (entryTime >= currentStart && entryTime <= now)
{
currentCount++;
if (entry.StatusCode >= 400)
{
currentErrors++;
}
}
else if (entryTime >= previousStart && entryTime < currentStart)
{
previousCount++;
if (entry.StatusCode >= 400)
{
previousErrors++;
}
}
}

if (previousCount == 0)
{
return string.Empty;
}

var currentRate = currentCount == 0 ? 0.0 : (double)currentErrors / currentCount;
var previousRate = (double)previousErrors / previousCount;

if (Math.Abs(currentRate - previousRate) < 0.0001)
{
return string.Empty;
}

if (currentRate > previousRate)
{
return $"<span class=\"dbp-trend dbp-trend--worse\" style=\"color: #e74c3c; margin-left: 4px; font-weight: bold;\" title=\"Error rate increased from {previousRate * 100:0.#}% to {currentRate * 100:0.#}%\">\u2191</span>";
}
else
{
return $"<span class=\"dbp-trend dbp-trend--better\" style=\"color: #27ae60; margin-left: 4px; font-weight: bold;\" title=\"Error rate decreased from {previousRate * 100:0.#}% to {currentRate * 100:0.#}%\">\u2193</span>";
}
}
}
3 changes: 3 additions & 0 deletions DebugProbe.AspNetCore/Options/DebugProbeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public int MaxBodyCaptureSizeKb
/// </summary>
public int SlowRequestThresholdMs { get; set; } = 1000;

public int LookbackMinutes { get; set; } = 15;


/// <summary>
/// Allows compare operations to target localhost and private network addresses.
/// Defaults to true in Development and false in other environments unless explicitly configured.
Expand Down
Loading