diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index e411cfe..6fd2089 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -20,7 +20,7 @@ a { } h2 { - margin-bottom: 10px; + margin: 0 0 4px; } h3 { @@ -41,6 +41,7 @@ h4 { } .toolbar, +.index-header, .topbar, .accordion-header, .accordion-meta, @@ -51,12 +52,81 @@ h4 { } .toolbar, +.index-header, .topbar, .accordion-header, .details-item { justify-content: space-between; } +.index-header { + gap: 16px; + margin-bottom: 14px; +} + +.muted { + margin: 0; + color: #666; + font-size: 13px; +} + +.filters { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(130px, 170px) minmax(130px, 170px) auto auto; + gap: 10px; + margin-bottom: 14px; +} + +.stats-bar { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.stat-tile { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 52px; + padding: 0 16px; + background: #fff; + border: 1px solid #e9e9e9; + border-radius: 8px; + text-align: center; +} + +.stat-tile strong { + color: #1f2937; + font-size: 22px; + line-height: 1; +} + +.stat-tile span { + color: #666; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; +} + +.filters input, +.filters select { + min-height: 36px; + padding: 0 10px; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + color: #222; + font: inherit; +} + +.filters input:focus, +.filters select:focus { + outline: 2px solid rgba(108, 92, 231, 0.18); + border-color: #6c5ce7; +} + .topbar { padding: 18px; background: #1b1b1b; @@ -140,6 +210,13 @@ table { border-collapse: collapse; } +.table-wrap { + overflow-x: auto; + background: #fff; + border: 1px solid #e9e9e9; + border-radius: 8px; +} + th, td { padding: 10px; @@ -149,16 +226,43 @@ td { th { background: #fafafa; + color: #555; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; } tr:hover { background: #f1f1f1; } +tbody tr:last-child td { + border-bottom: none; +} + .clickable-row { cursor: pointer; } +.method-pill { + display: inline-flex; + min-width: 54px; + justify-content: center; + padding: 4px 8px; + background: #f3f4f6; + border-radius: 999px; + color: #333; + font-size: 12px; + font-weight: 700; +} + +.empty-state, +.empty-row td { + padding: 24px; + color: #777; + text-align: center; +} + /* ========================= Section Titles ========================= */ @@ -245,6 +349,19 @@ tr:hover { padding: 14px; } + .index-header { + align-items: flex-start; + flex-direction: column; + } + + .filters { + grid-template-columns: 1fr; + } + + .stats-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .payload-group { padding: 14px; } @@ -373,13 +490,14 @@ pre { .copy-btn, .btn-clear, +.btn-secondary, .trace-id-button { border-radius: 4px; cursor: pointer; } .copy-btn, -.btn-clear { +.btn-secondary { background: #2d2d2d; border: 1px solid #444; color: #ccc; @@ -394,16 +512,41 @@ pre { } .copy-btn:hover, - .btn-clear:hover { + .btn-secondary:hover { background: #3a3a3a; } -.btn-clear { +.btn-secondary { padding: 6px 12px; color: #fff; font-size: 13px; } +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 36px; + background: #fff; + border-color: #ddd; + color: #333; +} + +.btn-secondary:hover { + background: #f3f3f3; +} + +.btn-clear svg { + width: 15px; + height: 15px; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} + .trace-id-button { padding: 6px 10px; background: #f3f4f6; diff --git a/DebugProbe.AspNetCore/Assets/html/index.html b/DebugProbe.AspNetCore/Assets/html/index.html index dd3cefc..cbe0fa1 100644 --- a/DebugProbe.AspNetCore/Assets/html/index.html +++ b/DebugProbe.AspNetCore/Assets/html/index.html @@ -1,21 +1,72 @@ -
-
-
-
- +
+
+
+

Requests

+

Showing {{total_count}} of {{total_count}}

- - - - - - - - - - - {{rows}} - -
TimeMethodPathStatus
-
\ No newline at end of file + +
+
+ {{total_requests}} + Requests +
+
+ {{avg_response_time}} + Avg +
+
+ {{slow_requests}} + Over 1s +
+
+ {{error_rate}} + Errors +
+
+ +
+ + + + + +
+ +
+ + + + + + + + + + + + {{rows}} + +
TimeMethodPathStatusDuration
+
+ + +
diff --git a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js index d8cff00..2773fe6 100644 --- a/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js +++ b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js @@ -26,3 +26,46 @@ document.querySelectorAll(".clickable-row[data-url]").forEach(row => { window.location.assign(row.dataset.url); }); }); + +const requestSearch = document.getElementById("requestSearch"); +const methodFilter = document.getElementById("methodFilter"); +const statusFilter = document.getElementById("statusFilter"); +const resetFiltersBtn = document.getElementById("resetFiltersBtn"); +const visibleCount = document.getElementById("visibleCount"); +const emptyFilterState = document.getElementById("emptyFilterState"); +const requestRows = Array.from(document.querySelectorAll("#requestTable tbody tr.clickable-row")); + +function applyRequestFilters() { + if (!requestRows.length) return; + + const search = (requestSearch?.value ?? "").trim().toLowerCase(); + const method = methodFilter?.value ?? ""; + const statusFamily = statusFilter?.value ?? ""; + let shown = 0; + + requestRows.forEach(row => { + const matchesSearch = !search || (row.dataset.search ?? "").toLowerCase().includes(search); + const matchesMethod = !method || row.dataset.method === method; + const matchesStatus = !statusFamily || row.dataset.statusFamily === statusFamily; + const isVisible = matchesSearch && matchesMethod && matchesStatus; + + row.hidden = !isVisible; + if (isVisible) shown++; + }); + + if (visibleCount) visibleCount.innerText = shown.toString(); + if (emptyFilterState) emptyFilterState.hidden = shown > 0; +} + +[requestSearch, methodFilter, statusFilter].forEach(control => { + control?.addEventListener("input", applyRequestFilters); + control?.addEventListener("change", applyRequestFilters); +}); + +resetFiltersBtn?.addEventListener("click", () => { + if (requestSearch) requestSearch.value = ""; + if (methodFilter) methodFilter.value = ""; + if (statusFilter) statusFilter.value = ""; + applyRequestFilters(); + requestSearch?.focus(); +}); diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index 10f5e18..03c6525 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -24,21 +24,49 @@ public static string BuildLayout(string content) public static string RenderIndexPage(List items) { + const int slowRequestThresholdMs = 1000; + var rows = string.Join("", items.Select(x => $@" - + {x.Timestamp:HH:mm:ss} - {Encode(x.Method)} - {Encode(x.Path)} - = 400 ? "#e74c3c" : "#2ecc71")}; font-weight:bold;""> - {x.StatusCode} - + {Encode(x.Method)} + {Encode(string.IsNullOrEmpty(x.Query) ? x.Path : $"{x.Path}{x.Query}")} + {x.StatusCode} + {x.DurationMs} ms " )); if (string.IsNullOrEmpty(rows)) - rows = "No data"; - - return BuildLayout(EmbeddedResources.Index.Replace("{{rows}}", rows)); + rows = "No data"; + + var methodOptions = string.Join("", items + .Select(x => x.Method) + .Where(method => !string.IsNullOrWhiteSpace(method)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(method => method, StringComparer.OrdinalIgnoreCase) + .Select(method => $@"")); + + var totalRequests = items.Count; + var averageResponseMs = totalRequests == 0 + ? 0 + : (int)Math.Round(items.Average(x => x.DurationMs)); + var slowRequests = items.Count(x => x.DurationMs >= slowRequestThresholdMs); + var errorRate = totalRequests == 0 + ? 0 + : items.Count(x => x.StatusCode >= 400) * 100d / totalRequests; + + return BuildLayout(EmbeddedResources.Index + .Replace("{{rows}}", rows) + .Replace("{{total_count}}", items.Count.ToString()) + .Replace("{{method_options}}", methodOptions) + .Replace("{{total_requests}}", FormatCompactNumber(totalRequests)) + .Replace("{{avg_response_time}}", $"{averageResponseMs} ms") + .Replace("{{slow_requests}}", FormatCompactNumber(slowRequests)) + .Replace("{{error_rate}}", $"{errorRate:0.#}%")); } public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string req, string res) @@ -119,4 +147,14 @@ private static string GetResponseGroupClass(int statusCode) return statusCode >= 400 ? "response-error" : ""; } + private static string FormatCompactNumber(int value) + { + return value switch + { + >= 1_000_000 => $"{value / 1_000_000d:0.#}M", + >= 1_000 => $"{value / 1_000d:0.#}K", + _ => value.ToString() + }; + } + } diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index 14c2cef..ba925a2 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -38,10 +38,11 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) var path = context.Request.Path.Value ?? string.Empty; var ignored = - path.StartsWith("/debug") || - path.StartsWith("/swagger") || - _options.IgnorePaths.Any(x => - path.StartsWith(x, StringComparison.OrdinalIgnoreCase)); + path.StartsWith("/debug", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/.well-known", StringComparison.OrdinalIgnoreCase) || + _options.IgnorePaths.Any(x => + path.StartsWith(x, StringComparison.OrdinalIgnoreCase)); if (ignored) { @@ -75,6 +76,9 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) finally { started.Stop(); + var durationMs = started.ElapsedTicks > 0 + ? Math.Max(1, started.ElapsedMilliseconds) + : 0; context.Response.Body = originalBody; @@ -91,7 +95,7 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) Query = context.Request.QueryString.ToString(), StatusCode = statusCode, RequestTimeUtc = DateTime.UtcNow, - DurationMs = started.ElapsedMilliseconds, + DurationMs = durationMs, RequestSize = context.Request.ContentLength ?? Encoding.UTF8.GetByteCount(requestBody), ResponseSize = Encoding.UTF8.GetByteCount(responseBody),