diff --git a/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererEnvDiffTests.cs b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererEnvDiffTests.cs new file mode 100644 index 0000000..6bb19d7 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererEnvDiffTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using DebugProbe.AspNetCore.Internal.Rendering; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace DebugProbe.AspNetCore.Tests.Rendering; + +public class HtmlRendererEnvDiffTests +{ + [Fact] + public void Render_index_page_with_AutoEnvironmentDiff_disabled_does_not_compare() + { + // Arrange + var options = new DebugProbeOptions { AutoEnvironmentDiff = false }; + var store = new DebugEntryStore(options); + + var entry1 = new DebugEntry + { + Id = "1", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"ok\"}" + }; + var entry2 = new DebugEntry + { + Id = "2", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"error\"}" + }; + + var devEnv = new DebugEnvironment { Environment = "Development" }; + var prodEnv = new DebugEnvironment { Environment = "Production" }; + + store.Add(entry1, devEnv); + store.Add(entry2, prodEnv); + + // Act + var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + + // Assert + Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", html); + } + + [Fact] + public void Render_index_page_with_same_environment_shows_no_badge() + { + // Arrange + var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; + var store = new DebugEntryStore(options); + + var entry1 = new DebugEntry + { + Id = "1", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"ok\"}" + }; + var entry2 = new DebugEntry + { + Id = "2", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"error\"}" + }; + + var devEnv = new DebugEnvironment { Environment = "Development" }; + + store.Add(entry1, devEnv); + store.Add(entry2, devEnv); + + // Act + var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + + // Assert + Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", html); + } + + [Fact] + public void Render_index_page_with_different_environments_and_identical_payloads_shows_no_badge() + { + // Arrange + var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; + var store = new DebugEntryStore(options); + + var entry1 = new DebugEntry + { + Id = "1", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"ok\"}" + }; + var entry2 = new DebugEntry + { + Id = "2", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"ok\"}" + }; + + var devEnv = new DebugEnvironment { Environment = "Development" }; + var prodEnv = new DebugEnvironment { Environment = "Production" }; + + store.Add(entry1, devEnv); + store.Add(entry2, prodEnv); + + // Act + var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + + // Assert + Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", html); + } + + [Fact] + public void Render_index_page_with_different_environments_and_differing_payloads_shows_badge() + { + // Arrange + var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; + var store = new DebugEntryStore(options); + + var entry1 = new DebugEntry + { + Id = "1", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"ok\"}" + }; + var entry2 = new DebugEntry + { + Id = "2", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"error\"}" + }; + + var devEnv = new DebugEnvironment { Environment = "Development" }; + var prodEnv = new DebugEnvironment { Environment = "Production" }; + + store.Add(entry1, devEnv); + store.Add(entry2, prodEnv); + + // Act + var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + + // Assert + Assert.Contains("class=\"dbp-badge dbp-badge-envdiff\"", html); + Assert.Contains("Payload differences detected between: Production, Development", html); + } + + [Fact] + public void Route_normalization_matches_slash_and_query_string() + { + // Arrange + var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; + var store = new DebugEntryStore(options); + + // entry1 path has trailing slash, entry2 path has query string. They should normalize to /api/users + var entry1 = new DebugEntry + { + Id = "1", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5), + Method = "GET", + Path = "/api/users/", + ResponseBody = "{\"status\": \"ok\"}" + }; + var entry2 = new DebugEntry + { + Id = "2", + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), + Path = "/api/users?id=123", + Method = "GET", + ResponseBody = "{\"status\": \"error\"}" + }; + + var devEnv = new DebugEnvironment { Environment = "Development" }; + var prodEnv = new DebugEnvironment { Environment = "Production" }; + + store.Add(entry1, devEnv); + store.Add(entry2, prodEnv); + + // Act + var html = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + + // Assert + Assert.Contains("class=\"dbp-badge dbp-badge-envdiff\"", html); + } + + [Fact] + public void Empty_store_and_single_entry_show_no_badge() + { + // Arrange + var options = new DebugProbeOptions { AutoEnvironmentDiff = true }; + var store = new DebugEntryStore(options); + + // Act & Assert (empty) + var htmlEmpty = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", htmlEmpty); + + // Add single entry + var entry = new DebugEntry + { + Id = "1", + Timestamp = DateTimeOffset.UtcNow, + Method = "GET", + Path = "/api/users", + ResponseBody = "{\"status\": \"ok\"}" + }; + store.Add(entry, new DebugEnvironment { Environment = "Development" }); + + // Act & Assert (single entry) + var htmlSingle = HtmlRenderer.RenderIndexPage(store.GetAll(), options); + Assert.DoesNotContain("class=\"dbp-badge dbp-badge-envdiff\"", htmlSingle); + } +} diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 86a6c4b..eaf0ae6 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -1023,6 +1023,12 @@ pre { border: 1px solid rgba(255, 200, 0, 0.3); } +.dbp-badge-envdiff { + background: rgba(231, 76, 60, 0.1); + color: #c0392b; + border: 1px solid rgba(231, 76, 60, 0.25); +} + /* ========================= Diff ========================= */ 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; + } + } +}); + diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index a5ab44b..55b5a9c 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -24,10 +24,70 @@ public static string BuildLayout(string content) .Replace("{{env_block}}", envBlock); } + private static string NormalizePath(string? path) + { + if (string.IsNullOrEmpty(path)) + return string.Empty; + + var queryIndex = path.IndexOf('?'); + var normalized = queryIndex >= 0 ? path[..queryIndex] : path; + + if (normalized.Length > 1 && normalized.EndsWith('/')) + { + normalized = normalized.TrimEnd('/'); + } + + return normalized; + } + public static string RenderIndexPage(List items, DebugProbeOptions? options = null) { options ??= new DebugProbeOptions(); var slowRequestThresholdMs = options.SlowRequestThresholdMs; + var store = DebugEntryStore.Instance; + + var routesWithDiffs = new HashSet(StringComparer.OrdinalIgnoreCase); + var routeDiffTooltips = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (options.AutoEnvironmentDiff && items.Count > 0) + { + var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in items) + { + var method = entry.Method ?? string.Empty; + var normalizedPath = NormalizePath(entry.Path); + var groupKey = $"{method}:{normalizedPath}"; + var env = store?.GetEnvironment(entry)?.Environment ?? "Unknown"; + + if (!groups.TryGetValue(groupKey, out var envDict)) + { + envDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + groups[groupKey] = envDict; + } + + if (!envDict.ContainsKey(env)) + { + envDict[env] = entry; + } + } + + foreach (var kvp in groups) + { + var envDict = kvp.Value; + if (envDict.Count >= 2) + { + var topTwo = envDict.Values.OrderByDescending(e => e.Timestamp).Take(2).ToList(); + var diffs = DebugProbe.AspNetCore.Internal.Compare.DebugEntryComparer.Compare(topTwo[0], topTwo[1]); + if (diffs != null && diffs.Count > 0) + { + routesWithDiffs.Add(kvp.Key); + var envNames = topTwo.Select(e => store?.GetEnvironment(e)?.Environment ?? "Unknown").Distinct(); + routeDiffTooltips[kvp.Key] = string.Join(", ", envNames); + } + } + } + } var rows = string.Join("", items.Select(x => { @@ -35,6 +95,19 @@ public static string RenderIndexPage(List items, DebugProbeOptions? var badge = RenderSlowBadge(TimeSpan.FromMilliseconds(x.DurationMs), options); var badgeHtml = string.IsNullOrEmpty(badge) ? "" : " " + badge; + var method = x.Method ?? string.Empty; + var normalizedPath = NormalizePath(x.Path); + var groupKey = $"{method}:{normalizedPath}"; + var envDiffBadgeHtml = ""; + + if (options.AutoEnvironmentDiff && routesWithDiffs.Contains(groupKey)) + { + if (routeDiffTooltips.TryGetValue(groupKey, out var envList)) + { + envDiffBadgeHtml = $@" ⚠ Env diff"; + } + } + return $@" items, DebugProbeOptions? class=""clickable-row""> {x.Timestamp:HH:mm:ss} {Encode(x.Method)} - {Encode(pathWithQuery)} + {Encode(pathWithQuery)}{envDiffBadgeHtml} {x.StatusCode} {x.DurationMs} ms{badgeHtml} "; @@ -65,7 +138,7 @@ public static string RenderIndexPage(List items, DebugProbeOptions? var errorRate = totalRequests == 0 ? 0 : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests; // Trend calculations - var store = DebugEntryStore.Instance; + store ??= DebugEntryStore.Instance; var now = DateTimeOffset.UtcNow; var limitTime = now.AddMinutes(-options.TrendLookbackMinutes); var midTime = now.AddMinutes(-options.TrendLookbackMinutes / 2.0); diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index aed9b0c..1f47949 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -63,6 +63,12 @@ public int MaxBodyCaptureSizeKb /// public bool CaptureOutgoingHttpClientRequests { get; set; } = true; + /// + /// Enables automatic comparison of trace payloads for the same endpoint across different environments. + /// Defaults to false. + /// + public bool AutoEnvironmentDiff { get; set; } = false; + /// /// Additional request paths to ignore. /// diff --git a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs index 21e9016..421acb7 100644 --- a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs +++ b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs @@ -34,6 +34,7 @@ public class DebugEntryStore public DebugEnvironment Environment { get; } private readonly ConcurrentQueue _queue = new(); + private readonly ConcurrentDictionary _entryEnvironments = new(); private readonly int _limit; public DebugEntryStore(DebugProbeOptions options) @@ -55,8 +56,17 @@ public DebugEntryStore(DebugProbeOptions options) } public void Add(DebugEntry entry) + { + Add(entry, Environment); + } + + public void Add(DebugEntry entry, DebugEnvironment environment) { _queue.Enqueue(entry); + if (environment != null && entry.Id != null) + { + _entryEnvironments[entry.Id] = environment; + } if (TryParseException(entry.ResponseBody, out var type, out var message)) { @@ -85,8 +95,20 @@ public void Add(DebugEntry entry) while (_queue.Count > _limit) { // ExceptionGroups counts are a running tally and must NOT be decremented on MaxEntries eviction. - _queue.TryDequeue(out _); + if (_queue.TryDequeue(out var evicted) && evicted.Id != null) + { + _entryEnvironments.TryRemove(evicted.Id, out _); + } + } + } + + public DebugEnvironment GetEnvironment(DebugEntry entry) + { + if (entry.Id != null && _entryEnvironments.TryGetValue(entry.Id, out var env)) + { + return env; } + return Environment; } public List GetAll() @@ -102,6 +124,7 @@ public List GetAll() public void Clear() { while (_queue.TryDequeue(out _)) { } + _entryEnvironments.Clear(); ExceptionGroups.Clear(); }