From 07e2a4e914e59382fbedbcb48ecc3491cd723462 Mon Sep 17 00:00:00 2001 From: DevSars24 Date: Thu, 16 Jul 2026 02:01:30 +0530 Subject: [PATCH 1/2] feat: add request rate sparkline and error rate trend indicator --- .../Rendering/HtmlRendererTests.cs | 80 +++++++++++++ .../Internal/Rendering/HtmlRenderer.cs | 112 +++++++++++++++++- .../Options/DebugProbeOptions.cs | 3 + 3 files changed, 193 insertions(+), 2 deletions(-) diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs index 813946e..bfd03d4 100644 --- a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs @@ -523,4 +523,84 @@ public void Render_details_page_outgoing_call_boundary_minus_one() Assert.Contains("999 ms", html); Assert.DoesNotContain(" 999 ms (), 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 + { + 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 + { + 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 + { + // 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 + { + // 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); + } } diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index d4f6880..5f7c966 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -99,6 +99,9 @@ public static string RenderIndexPage(List items, DebugProbeOptions? "; } + var sparklineHtml = BuildRequestRateSparkline(items, options); + var errorRateTrendHtml = ComputeErrorRateTrend(items, options); + var pageHtml = EmbeddedResources.Index; if (!string.IsNullOrEmpty(exceptionPanel)) { @@ -113,10 +116,10 @@ public static string RenderIndexPage(List 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) @@ -560,4 +563,109 @@ private static string RenderSlowBadge(TimeSpan duration, DebugProbeOptions optio return string.Empty; } + /// + /// Builds an SVG sparkline showing request rate over the configured lookback window. + /// + internal static string BuildRequestRateSparkline(List 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(); + 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 $@""; + } + + /// + /// Computes error rate trends comparing the current window to the previous window. + /// + internal static string ComputeErrorRateTrend(List 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 $"\u2191"; + } + else + { + return $"\u2193"; + } + } } diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 2360515..938b802 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -33,6 +33,9 @@ public int MaxBodyCaptureSizeKb /// public int SlowRequestThresholdMs { get; set; } = 1000; + public int LookbackMinutes { get; set; } = 15; + + /// /// Allows compare operations to target localhost and private network addresses. /// Defaults to true in Development and false in other environments unless explicitly configured. From ea1c3c0b27a763d44e316c5b517c1e0aa3611acd Mon Sep 17 00:00:00 2001 From: DevSars24 Date: Thu, 16 Jul 2026 01:04:48 +0530 Subject: [PATCH 2/2] Implement Feature 8: Keyboard Shortcuts for DebugProbe dashboard --- .../Assets/js/debugprobe-ui.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js index f8dfa3d..79a7000 100644 --- a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js +++ b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js @@ -471,3 +471,63 @@ document.addEventListener("DOMContentLoaded", () => { .replace(/'/g, "'"); } }); + +// 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; + } + } +}); +