diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTrendTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTrendTests.cs new file mode 100644 index 0000000..207e4df --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTrendTests.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using DebugProbe.AspNetCore.Internal.Rendering; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using Microsoft.Extensions.Options; +using Xunit; +using DebugProbe.AspNetCore.Storage; + +namespace DebugProbe.AspNetCore.Tests.Rendering; + +public class HtmlRendererTrendTests +{ + [Fact] + public void Render_index_page_with_no_data_generates_flat_sparkline() + { + var html = HtmlRenderer.RenderIndexPage([], new DebugProbeOptions { TrendLookbackMinutes = 30 }); + + // Flat sparkline check: Y values should all be 14 + Assert.Contains("14", html); + Assert.Contains("trend-neutral", html); + Assert.Contains("→", html); + } + + [Fact] + public void Render_index_page_with_increased_errors_shows_trend_up() + { + var now = DateTimeOffset.UtcNow; + var options = new DebugProbeOptions { TrendLookbackMinutes = 10 }; + + var items = new List + { + // Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate. + new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" }, + // Current half: [now - 5m, now]. 1 request, 1 error -> 100% error rate. + new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 500, Method = "GET", Path = "/api" } + }; + + var html = HtmlRenderer.RenderIndexPage(items, options); + + Assert.Contains("trend-up", html); + Assert.Contains("↑", html); + } + + [Fact] + public void Render_index_page_with_decreased_errors_shows_trend_down() + { + var now = DateTimeOffset.UtcNow; + var options = new DebugProbeOptions { TrendLookbackMinutes = 10 }; + + var items = new List + { + // Preceding half: [now - 10m, now - 5m). 1 request, 1 error -> 100% error rate. + new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 500, Method = "GET", Path = "/api" }, + // Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate. + new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" } + }; + + var html = HtmlRenderer.RenderIndexPage(items, options); + + Assert.Contains("trend-down", html); + Assert.Contains("↓", html); + } + + [Fact] + public void Render_index_page_with_unchanged_errors_shows_trend_neutral() + { + var now = DateTimeOffset.UtcNow; + var options = new DebugProbeOptions { TrendLookbackMinutes = 10 }; + + var items = new List + { + // Preceding half: [now - 10m, now - 5m). 1 request, 0 errors -> 0% error rate. + new() { Id = "1", Timestamp = now.AddMinutes(-7), StatusCode = 200, Method = "GET", Path = "/api" }, + // Current half: [now - 5m, now]. 1 request, 0 errors -> 0% error rate. + new() { Id = "2", Timestamp = now.AddMinutes(-2), StatusCode = 200, Method = "GET", Path = "/api" } + }; + + var html = HtmlRenderer.RenderIndexPage(items, options); + + Assert.Contains("trend-neutral", html); + Assert.Contains("→", html); + } + + [Fact] + public void Render_index_page_scenario_a_and_b_regression_test() + { + var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 }; + + var now = DateTimeOffset.UtcNow; + + // Scenario A: 8 successes at t = now - 90s, then 5 errors at t = now - 5s + // Since MaxEntries = 10, 3 successes are evicted. 5 successes remain in previous window, 5 errors in current. + // TotalB = 5 > 0, TotalA = 5 > 0. + var itemsA = new List(); + for (int i = 0; i < 5; i++) + { + itemsA.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" }); + } + for (int i = 0; i < 5; i++) + { + itemsA.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); + } + + // Expected: should show Trend Up (↑) because previous window is valid and error rate went up from 0% to 100% + var htmlA = HtmlRenderer.RenderIndexPage(itemsA, options); + Assert.Contains("trend-up", htmlA); + Assert.Contains("↑", htmlA); + + // Scenario B: 8 recovery successes are sent, evicting all baseline successes and some errors. + // Queue now has 8 recovery successes and 2 errors (all in current window). + // Previous window is empty (TotalB = 0). + var itemsB = new List(); + for (int i = 0; i < 2; i++) + { + itemsB.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); + } + for (int i = 0; i < 8; i++) + { + itemsB.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" }); + } + + // Expected: should show Trend Neutral (→) because previous window is empty + var htmlB = HtmlRenderer.RenderIndexPage(itemsB, options); + Assert.Contains("trend-neutral", htmlB); + Assert.Contains("→", htmlB); + } + + [Fact] + public void Render_index_page_previous_window_evicted_shows_neutral() + { + var options = new DebugProbeOptions { MaxEntries = 10, TrendLookbackMinutes = 2 }; + var items = new List(); + + var now = DateTimeOffset.UtcNow; + // All baseline entries from previous window are evicted. Previous window has 0 entries. + for (int i = 0; i < 10; i++) + { + items.Add(new DebugEntry { Id = $"recovery-{i}", Timestamp = now.AddSeconds(-2), StatusCode = 200, Method = "GET", Path = "/api" }); + } + + var html = HtmlRenderer.RenderIndexPage(items, options); + Assert.Contains("trend-neutral", html); + Assert.Contains("→", html); + } + + [Fact] + public void Render_index_page_previous_window_no_traffic_shows_neutral() + { + var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 }; + var items = new List(); + + var now = DateTimeOffset.UtcNow; + // Only 5 errors are sent now. No traffic occurred in the previous window. + for (int i = 0; i < 5; i++) + { + items.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); + } + + var html = HtmlRenderer.RenderIndexPage(items, options); + Assert.Contains("trend-neutral", html); + Assert.Contains("→", html); + } + + [Fact] + public void Render_index_page_happy_path_with_no_eviction_flips_trend() + { + var options = new DebugProbeOptions { MaxEntries = 100, TrendLookbackMinutes = 2 }; + var store = new DebugEntryStore(options); + + var now = DateTimeOffset.UtcNow; + // Phase 1: 8 successes at t = now - 90s (previous window) + for (int i = 0; i < 8; i++) + { + store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 200, Method = "GET", Path = "/api" }); + } + + // Phase 2: 5 errors at t = now - 5s (current window) + for (int i = 0; i < 5; i++) + { + store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 500, Method = "GET", Path = "/api" }); + } + + var htmlErrors = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + Assert.Contains("trend-up", htmlErrors); + Assert.Contains("↑", htmlErrors); + + store.Clear(); + + // Phase 3: 5 errors at t = now - 90s (previous window) + for (int i = 0; i < 5; i++) + { + store.Add(new DebugEntry { Id = $"error-{i}", Timestamp = now.AddSeconds(-90), StatusCode = 500, Method = "GET", Path = "/api" }); + } + + // Phase 4: 8 successes at t = now - 5s (current window) + for (int i = 0; i < 8; i++) + { + store.Add(new DebugEntry { Id = $"success-{i}", Timestamp = now.AddSeconds(-5), StatusCode = 200, Method = "GET", Path = "/api" }); + } + + var htmlRecovery = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + Assert.Contains("trend-down", htmlRecovery); + Assert.Contains("↓", htmlRecovery); + } + + [Fact] + public void Options_validator_rejects_trend_lookback_less_than_two() + { + var validator = new DebugProbeOptionsValidator(); + var options = new DebugProbeOptions { TrendLookbackMinutes = 1 }; + + var result = validator.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("TrendLookbackMinutes must be greater than or equal to 2", result.FailureMessage); + } +} diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 402ca65..86a6c4b 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -77,7 +77,7 @@ h4 { .stats-bar { display: grid; - grid-template-columns: repeat(4, minmax(150px, 1fr)); + grid-template-columns: repeat(5, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; } @@ -108,6 +108,27 @@ h4 { text-transform: uppercase; } +.trend-arrow { + display: inline-block; + font-size: 16px; + margin-left: 4px; + vertical-align: middle; + line-height: 1; +} + +.trend-up { + color: #e74c3c; +} + +.trend-down { + color: #27ae60; +} + +.trend-neutral { + color: #9ca3af; +} + + .filters input, .filters select { min-height: 36px; diff --git a/DebugProbe.AspNetCore/Assets/html/index.html b/DebugProbe.AspNetCore/Assets/html/index.html index cbe0fa1..e330e94 100644 --- a/DebugProbe.AspNetCore/Assets/html/index.html +++ b/DebugProbe.AspNetCore/Assets/html/index.html @@ -20,9 +20,15 @@

Requests

Over 1s
- {{error_rate}} + {{error_rate}}{{error_trend}} Errors
+
+
+ {{sparkline}} +
+ Activity +
diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index d4f6880..a5ab44b 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -64,8 +64,91 @@ public static string RenderIndexPage(List items, DebugProbeOptions? var slowRequests = slowRequestThresholdMs > 0 ? items.Count(x => x.DurationMs >= slowRequestThresholdMs) : 0; var errorRate = totalRequests == 0 ? 0 : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests; - var exceptionPanel = ""; + // Trend calculations var store = DebugEntryStore.Instance; + var now = DateTimeOffset.UtcNow; + var limitTime = now.AddMinutes(-options.TrendLookbackMinutes); + var midTime = now.AddMinutes(-options.TrendLookbackMinutes / 2.0); + + int[] buckets = new int[options.TrendLookbackMinutes]; + int totalA = 0; + int errorsA = 0; + int totalB = 0; + int errorsB = 0; + + foreach (var entry in items) + { + var t = entry.Timestamp; + var elapsed = now - t; + if (elapsed.TotalMinutes < options.TrendLookbackMinutes) + { + double mins = Math.Max(0.0, elapsed.TotalMinutes); + int bucketIndex = options.TrendLookbackMinutes - 1 - (int)Math.Floor(mins); + if (bucketIndex >= 0 && bucketIndex < options.TrendLookbackMinutes) + { + buckets[bucketIndex]++; + } + } + + if (t >= midTime) + { + totalA++; + if (entry.StatusCode >= 400) + { + errorsA++; + } + } + else if (t >= limitTime && t < midTime) + { + totalB++; + if (entry.StatusCode >= 400) + { + errorsB++; + } + } + } + + int maxVal = buckets.Max(); + var pointsList = new List(); + for (int i = 0; i < options.TrendLookbackMinutes; i++) + { + double x = (double)i / (options.TrendLookbackMinutes - 1) * 120.0; + double y = maxVal == 0 ? 14.0 : 26.0 - ((double)buckets[i] / maxVal * 24.0); + pointsList.Add($"{x:0.##},{y:0.##}"); + } + string pointsString = string.Join(" ", pointsList); + + string sparklineSvg = $@""; + + double errorRateA = totalA == 0 ? 0.0 : (double)errorsA / totalA; + double errorRateB = totalB == 0 ? 0.0 : (double)errorsB / totalB; + + string trendArrow; + string arrowClass; + if (totalA == 0 || totalB == 0) + { + trendArrow = "→"; + arrowClass = "trend-neutral"; + } + else if (errorRateA > errorRateB) + { + trendArrow = "↑"; + arrowClass = "trend-up"; + } + else if (errorRateA < errorRateB) + { + trendArrow = "↓"; + arrowClass = "trend-down"; + } + else + { + trendArrow = "→"; + arrowClass = "trend-neutral"; + } + + string errorTrendHtml = $" {trendArrow}"; + + var exceptionPanel = ""; if (store != null && !store.ExceptionGroups.IsEmpty) { var sortedGroups = store.ExceptionGroups.Values @@ -116,7 +199,9 @@ public static string RenderIndexPage(List items, DebugProbeOptions? .Replace("{{total_requests}}", FormatCompactNumber(totalRequests)) .Replace("{{avg_response_time}}", $"{averageResponseMs} ms") .Replace("{{slow_requests}}", FormatCompactNumber(slowRequests)) - .Replace("{{error_rate}}", $"{errorRate:0.#}%")); + .Replace("{{error_rate}}", $"{errorRate:0.#}%") + .Replace("{{error_trend}}", errorTrendHtml) + .Replace("{{sparkline}}", sparklineSvg)); } public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string req, string res, DebugProbeOptions? options = null) diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 2360515..aed9b0c 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -33,6 +33,12 @@ public int MaxBodyCaptureSizeKb /// public int SlowRequestThresholdMs { get; set; } = 1000; + /// + /// Lookback window in minutes for the request rate sparkline and error rate trend. + /// Defaults to 30. Must be greater than or equal to 2. + /// + public int TrendLookbackMinutes { get; set; } = 2; + /// /// Allows compare operations to target localhost and private network addresses. /// Defaults to true in Development and false in other environments unless explicitly configured. diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs index 008a002..46f8467 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; namespace DebugProbe.AspNetCore.Options; @@ -17,6 +17,14 @@ public ValidateOptionsResult Validate( $"Provided value: {options.MaxEntries}."); } + if (options.TrendLookbackMinutes < 2) + { + return ValidateOptionsResult.Fail( + $"DebugProbe configuration is invalid. " + + $"TrendLookbackMinutes must be greater than or equal to 2 (to allow splitting into two windows). " + + $"Provided value: {options.TrendLookbackMinutes}."); + } + return ValidateOptionsResult.Success; } } \ No newline at end of file diff --git a/DebugProbe.SampleApi/Program.cs b/DebugProbe.SampleApi/Program.cs index 0dc9a82..67a6241 100644 --- a/DebugProbe.SampleApi/Program.cs +++ b/DebugProbe.SampleApi/Program.cs @@ -35,4 +35,6 @@ return Results.Ok(new { delayedMs = milliseconds }); }); +app.MapControllers(); + app.Run();