diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index a40a357eb43..086e6fafb9c 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -3,7 +3,9 @@ using System.Diagnostics; using System.Text; +using System.Text.Json.Nodes; using System.Threading.Channels; +using System.Xml.Linq; using Microsoft.AspNetCore.Mvc; using Stress.ApiService; @@ -184,6 +186,8 @@ async IAsyncEnumerable WriteOutput() var xmlWithComments = @""; + var xmlWithUrl = new XElement(new XElement("url", "http://localhost:8080")).ToString(); + // From https://microsoftedge.github.io/Demos/json-dummy-data/ var jsonLarge = File.ReadAllText(Path.Combine("content", "example.json")); @@ -194,6 +198,11 @@ async IAsyncEnumerable WriteOutput() 1 ]"; + var jsonWithUrl = new JsonObject + { + ["url"] = "http://localhost:8080" + }.ToString(); + var sb = new StringBuilder(); for (int i = 0; i < 26; i++) { @@ -203,9 +212,15 @@ async IAsyncEnumerable WriteOutput() logger.LogInformation(@"XML large content: {XmlLarge} XML comment content: {XmlComment} +XML URL content: {XmlUrl} JSON large content: {JsonLarge} JSON comment content: {JsonComment} -Long line content: {LongLines}", xmlLarge, xmlWithComments, jsonLarge, jsonWithComments, sb.ToString()); +JSON URL content: {JsonUrl} +Long line content: {LongLines} +URL content: {UrlContent} +Empty content: {EmptyContent} +Whitespace content: {WhitespaceContent} +Null content: {NullContent}", xmlLarge, xmlWithComments, xmlWithUrl, jsonLarge, jsonWithComments, jsonWithUrl, sb.ToString(), "http://localhost:8080", "", " ", null); return "Log with formatted data"; }); diff --git a/src/Aspire.Dashboard/Components/Controls/GridValue.razor b/src/Aspire.Dashboard/Components/Controls/GridValue.razor index 60a243d3fca..0627f6153db 100644 --- a/src/Aspire.Dashboard/Components/Controls/GridValue.razor +++ b/src/Aspire.Dashboard/Components/Controls/GridValue.razor @@ -8,21 +8,21 @@ @if (EnableMasking && IsMasked) { - + ●●●●●●●● } else { - + @ContentBeforeValue @if (EnableHighlighting && !string.IsNullOrEmpty(HighlightText)) { } - else + else if (_formattedValue != null) { - @Value + @((MarkupString)_formattedValue) } @ContentAfterValue diff --git a/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs index 26e4b93d66e..5ca4f3f142a 100644 --- a/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs @@ -1,9 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; +using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Resources; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Controls; @@ -81,10 +84,19 @@ public partial class GridValue [Parameter] public string PostCopyToolTip { get; set; } = null!; + [Parameter] + public bool StopClickPropagation { get; set; } + + [Inject] + public required IJSRuntime JS { get; init; } + private readonly Icon _maskIcon = new Icons.Regular.Size16.EyeOff(); private readonly Icon _unmaskIcon = new Icons.Regular.Size16.Eye(); + private readonly string _cellTextId = $"celltext-{Guid.NewGuid():N}"; private readonly string _copyId = $"copy-{Guid.NewGuid():N}"; private readonly string _menuAnchorId = $"menu-{Guid.NewGuid():N}"; + private string? _value; + private string? _formattedValue; private bool _isMenuOpen; protected override void OnInitialized() @@ -93,6 +105,37 @@ protected override void OnInitialized() PostCopyToolTip = Loc[nameof(ControlsStrings.GridValueCopied)]; } + protected override void OnParametersSet() + { + if (_value != Value) + { + _value = Value; + + if (UrlParser.TryParse(_value, WebUtility.HtmlEncode, out var modifiedText)) + { + _formattedValue = modifiedText; + } + else + { + _formattedValue = WebUtility.HtmlEncode(_value); + } + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // If the value and formatted value are different then there are hrefs in the text. + // Add a click event to the cell text that stops propagation if a href is clicked. + // This prevents details view from opening when the value is in a main page grid. + if (StopClickPropagation && _value != _formattedValue) + { + await JS.InvokeVoidAsync("setCellTextClickHandler", _cellTextId); + } + } + } + private string GetContainerClass() => EnableMasking ? "container masking-enabled" : "container"; private async Task ToggleMaskStateAsync() diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor index a7b3ae239a7..8170fc4be37 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor @@ -9,7 +9,8 @@ + HighlightText="@FilterText" + StopClickPropagation="true"> diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor index c73d6b8c45e..a8857ef3579 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor @@ -10,7 +10,8 @@ EnableHighlighting="true" HighlightText="@FilterText" PreCopyToolTip="@Loc[nameof(Columns.SourceColumnDisplayCopyCommandToClipboard)]" - ToolTip="@Tooltip"> + ToolTip="@Tooltip" + StopClickPropagation="true"> @if (ContentAfterValue is not null) { diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogLevelParser.cs b/src/Aspire.Dashboard/ConsoleLogs/LogLevelParser.cs deleted file mode 100644 index b0afbcaaaee..00000000000 --- a/src/Aspire.Dashboard/ConsoleLogs/LogLevelParser.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.RegularExpressions; - -namespace Aspire.Dashboard.ConsoleLogs; - -public static partial class LogLevelParser -{ - private static readonly Regex s_logLevelRegex = GenerateLogLevelRegex(); - - public static bool StartsWithLogLevelHeader(string text) => s_logLevelRegex.IsMatch(text); - - // Regular expression that detects log levels used as indicators - // of the first line of a log entry, skipping any ANSI control sequences - // that may come first. - [GeneratedRegex( - """ - ^ # start of string - (\x1B\[\d{1,2}m)* # zero or more ANSI control sequences - (trce|dbug|info|warn|fail|crit) # one of the log level names - (\x1B\[\d{1,2}m)* # zero or more ANSI control sequences - : # colon, followed by arbitrary content that is not matched - """, - RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant | RegexOptions.IgnorePatternWhitespace)] - private static partial Regex GenerateLogLevelRegex(); -} diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs b/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs index 0c40faf4bb1..63ee8abe4e6 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs @@ -31,19 +31,30 @@ public LogEntry CreateLogEntry(string rawText, bool isErrorOutput) timestamp = timestampParseResult.Value.Timestamp.UtcDateTime; } - // 2. HTML Encode the raw text for security purposes - content = WebUtility.HtmlEncode(content); + Func callback = (s) => + { + // This callback is run on text that isn't transformed into a clickable URL. - // 3. Parse the content to look for ANSI Control Sequences and color them if possible - var conversionResult = AnsiParser.ConvertToHtml(content, _residualState); - content = conversionResult.ConvertedText; - _residualState = conversionResult.ResidualState; + // 3a. HTML Encode the raw text for security purposes + var updatedText = WebUtility.HtmlEncode(s); - // 4. Parse the content to look for URLs and make them links if possible - if (UrlParser.TryParse(content, out var modifiedText)) + // 3b. Parse the content to look for ANSI Control Sequences and color them if possible + var conversionResult = AnsiParser.ConvertToHtml(updatedText, _residualState); + updatedText = conversionResult.ConvertedText; + _residualState = conversionResult.ResidualState; + + return updatedText ?? string.Empty; + }; + + // 3. Parse the content to look for URLs and make them links if possible + if (UrlParser.TryParse(content, callback, out var modifiedText)) { content = modifiedText; } + else + { + content = callback(content); + } // 5. Create the LogEntry var logEntry = new LogEntry diff --git a/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs b/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs index f139cdafd15..0b19a200baa 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs @@ -12,20 +12,22 @@ public static partial class UrlParser { private static readonly Regex s_urlRegEx = GenerateUrlRegEx(); - public static bool TryParse(string? text, [NotNullWhen(true)] out string? modifiedText) + public static bool TryParse(string? text, Func? nonMatchFragmentCallback, [NotNullWhen(true)] out string? modifiedText) { if (text is not null) { var urlMatch = s_urlRegEx.Match(text); - var builder = new StringBuilder(text.Length * 2); + StringBuilder? builder = null; var nextCharIndex = 0; while (urlMatch.Success) { + builder ??= new StringBuilder(text.Length * 2); + if (urlMatch.Index > 0) { - builder.Append(text[(nextCharIndex)..urlMatch.Index]); + AppendNonMatchFragment(builder, nonMatchFragmentCallback, text[(nextCharIndex)..urlMatch.Index]); } var urlStart = urlMatch.Index; @@ -36,11 +38,11 @@ public static bool TryParse(string? text, [NotNullWhen(true)] out string? modifi urlMatch = urlMatch.NextMatch(); } - if (builder.Length > 0) + if (builder?.Length > 0) { if (nextCharIndex < text.Length) { - builder.Append(text[(nextCharIndex)..]); + AppendNonMatchFragment(builder, nonMatchFragmentCallback, text[(nextCharIndex)..]); } modifiedText = builder.ToString(); @@ -50,17 +52,30 @@ public static bool TryParse(string? text, [NotNullWhen(true)] out string? modifi modifiedText = null; return false; + + static void AppendNonMatchFragment(StringBuilder stringBuilder, Func? nonMatchFragmentCallback, string text) + { + if (nonMatchFragmentCallback != null) + { + text = nonMatchFragmentCallback(text); + } + + stringBuilder.Append(text); + } } // Regular expression that detects http/https URLs in a log entry - // Based on the RegEx used in Windows Terminal for the same purpose, but limited - // to only http/https URLs + // Based on the RegEx used in Windows Terminal for the same purpose. Some modifications: + // - Can start at a non word boundary. This behavior is similar to how GitHub matches URLs in pretty printed code. + // - Limited to only http/https URLs. + // - Ignore case. That means it matches URLs starting with http and HTTP. // // Explanation: - // /b - Match must start at a word boundary (after whitespace or at the start of the text) // https?:// - http:// or https:// // [-A-Za-z0-9+&@#/%?=~_|$!:,.;]* - Any character in the list, matched zero or more times. // [A-Za-z0-9+&@#/%=~_|$] - Any character in the list, matched exactly once - [GeneratedRegex("\\bhttps?://[-A-Za-z0-9+&@#/%?=~_|$!:,.;]*[A-Za-z0-9+&@#/%=~_|$]")] - private static partial Regex GenerateUrlRegEx(); + [GeneratedRegex( + "https?://[-A-Za-z0-9+&@#/%?=~_|$!:,.;]*[A-Za-z0-9+&@#/%=~_|$]", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + public static partial Regex GenerateUrlRegEx(); } diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 5e0939e04f8..f26fe781674 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -355,4 +355,20 @@ window.registerOpenTextVisualizerOnClick = function(layout) { window.unregisterOpenTextVisualizerOnClick = function (obj) { document.removeEventListener('click', obj.onClickListener); -} +}; + +window.setCellTextClickHandler = function (id) { + var cellTextElement = document.getElementById(id); + if (!cellTextElement) { + return; + } + + cellTextElement.addEventListener('click', e => { + // Propagation behavior: + // - Link click stops. Link will open in a new window. + // - Any other text allows propagation. Potentially opens details view. + if (isElementTagName(e.target, 'a')) { + e.stopPropagation(); + } + }); +}; diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs index bbd2765b74e..2bf03f9a7f0 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs @@ -233,4 +233,17 @@ public void InsertSorted_TrimsToMaximumEntryCount_OutOfOrder() l => Assert.Equal("2", l.Content), l => Assert.Equal("3", l.Content)); } + + [Fact] + public void CreateLogEntry_AnsiAndUrl_HasUrlAnchor() + { + // Arrange + var parser = new LogParser(); + + // Act + var entry = parser.CreateLogEntry("\x1b[36mhttps://www.example.com\u001b[0m", isErrorOutput: false); + + // Assert + Assert.Equal("https://www.example.com", entry.Content); + } } diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogLevelParserTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogLevelParserTests.cs deleted file mode 100644 index cc067498d3f..00000000000 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogLevelParserTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Dashboard.ConsoleLogs; -using Xunit; - -namespace Aspire.Dashboard.Tests.ConsoleLogsTests; - -public class LogLevelParserTests -{ - [Theory] - [InlineData("", false)] - [InlineData(" ", false)] - [InlineData("This is some text without any log level", false)] - [InlineData("This is some text that does not start with a log level info", false)] - [InlineData("crit:", true)] - [InlineData("dbug:", true)] - [InlineData("fail:", true)] - [InlineData("info:", true)] - [InlineData("trce:", true)] - [InlineData("warn:", true)] - [InlineData("\x1B[32minfo\x1B[39m:", true)] - public void StartsWithLogLevel_ReturnsCorrectResult(string input, bool expectedResult) - { - var result = LogLevelParser.StartsWithLogLevelHeader(input); - - Assert.Equal(expectedResult, result); - } -} diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs index 3f8e4b32713..510a2457859 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using Aspire.Dashboard.ConsoleLogs; using Xunit; @@ -15,7 +16,7 @@ public class UrlParserTests [InlineData("This is some text without any urls")] public void TryParse_NoUrl_ReturnsFalse(string? input) { - var result = UrlParser.TryParse(input, out var _); + var result = UrlParser.TryParse(input, WebUtility.HtmlEncode, out var _); Assert.False(result); } @@ -26,7 +27,7 @@ public void TryParse_NoUrl_ReturnsFalse(string? input) [InlineData("This is some text with a https://bing.com/ in the middle", true, "This is some text with a https://bing.com/ in the middle")] public void TryParse_ReturnsCorrectResult(string input, bool expectedResult, string? expectedOutput) { - var result = UrlParser.TryParse(input, out var modifiedText); + var result = UrlParser.TryParse(input, WebUtility.HtmlEncode, out var modifiedText); Assert.Equal(expectedResult, result); Assert.Equal(expectedOutput, modifiedText); @@ -42,7 +43,7 @@ public void TryParse_ReturnsCorrectResult(string input, bool expectedResult, str [InlineData("http://bing", "http://bing")] public void TryParse_SupportedUrlFormats(string input, string? expectedOutput) { - var result = UrlParser.TryParse(input, out var modifiedText); + var result = UrlParser.TryParse(input, WebUtility.HtmlEncode, out var modifiedText); Assert.True(result); Assert.Equal(expectedOutput, modifiedText); @@ -54,8 +55,32 @@ public void TryParse_SupportedUrlFormats(string input, string? expectedOutput) [InlineData("ftp://user:pass@ftp.localhost.com/")] public void TryParse_UnsupportedUrlFormats(string input) { - var result = UrlParser.TryParse(input, out var _); + var result = UrlParser.TryParse(input, WebUtility.HtmlEncode, out var _); Assert.False(result); } + + [Theory] + [InlineData("http://localhost:8080", "http://localhost:8080</url>")] + [InlineData("http://localhost:8080\"", "http://localhost:8080"")] + public void TryParse_ExcludeInvalidTrailingChars(string input, string? expectedOutput) + { + var result = UrlParser.TryParse(input, WebUtility.HtmlEncode, out var modifiedText); + Assert.True(result); + + Assert.Equal(expectedOutput, modifiedText); + } + + [Theory] + [InlineData("http://www.localhost:8080")] + [InlineData("HTTP://WWW.LOCALHOST:8080")] + [InlineData("mhttp://www.localhost:8080")] + [InlineData("httphttp://www.localhost:8080")] + [InlineData(" http://www.localhost:8080")] + public void GenerateUrlRegEx_MatchUrlAfterContent(string content) + { + var regex = UrlParser.GenerateUrlRegEx(); + var match = regex.Match(content); + Assert.Equal("http://www.localhost:8080", match.Value.ToLowerInvariant()); + } }