Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion playground/Stress/Stress.ApiService/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -184,6 +186,8 @@ async IAsyncEnumerable<string> WriteOutput()

var xmlWithComments = @"<hello><!-- world --></hello>";

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"));

Expand All @@ -194,6 +198,11 @@ async IAsyncEnumerable<string> WriteOutput()
1
]";

var jsonWithUrl = new JsonObject
{
["url"] = "http://localhost:8080"
}.ToString();

var sb = new StringBuilder();
for (int i = 0; i < 26; i++)
{
Expand All @@ -203,9 +212,15 @@ async IAsyncEnumerable<string> 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";
});
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Dashboard/Components/Controls/GridValue.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@

@if (EnableMasking && IsMasked)
{
<span class="grid-value masked">
<span class="grid-value masked" id="@_cellTextId">
&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;
</span>
}
else
{
<span class="grid-value" title="@(ToolTip ?? Value)">
<span class="grid-value" title="@(ToolTip ?? Value)" id="@_cellTextId">
@ContentBeforeValue
@if (EnableHighlighting && !string.IsNullOrEmpty(HighlightText))
{
<FluentHighlighter HighlightedText="@HighlightText" Text="@Value" />
}
else
{
@Value
@((MarkupString)(_formattedValue ?? string.Empty))
}
@ContentAfterValue
</span>
Expand Down
43 changes: 43 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
<GridValue Value="@LogEntry.Message"
ValueDescription="@Loc[nameof(StructuredLogs.StructuredLogsMessageColumnHeader)]"
EnableHighlighting="true"
HighlightText="@FilterText">
HighlightText="@FilterText"
StopClickPropagation="true">
<ContentInButtonArea>
<ExceptionDetails ExceptionText="@_exceptionText" />
</ContentInButtonArea>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
EnableHighlighting="true"
HighlightText="@FilterText"
PreCopyToolTip="@Loc[nameof(Columns.SourceColumnDisplayCopyCommandToClipboard)]"
ToolTip="@Tooltip">
ToolTip="@Tooltip"
StopClickPropagation="true">
<ContentAfterValue>
@if (ContentAfterValue is not null)
{
Expand Down
27 changes: 19 additions & 8 deletions src/Aspire.Dashboard/ConsoleLogs/LogParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> 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
Expand Down
30 changes: 22 additions & 8 deletions src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>? 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;
Expand All @@ -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();
Expand All @@ -50,17 +52,29 @@ public static bool TryParse(string? text, [NotNullWhen(true)] out string? modifi

modifiedText = null;
return false;

static void AppendNonMatchFragment(StringBuilder stringBuilder, Func<string, string>? 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
//
// Explanation:
// /b - Match must start at a word boundary (after whitespace or at the start of the text)
// (?<=m|\\b) - Match must start at a word boundary (after whitespace or at the start of the text) or "m".
// "m" is a trailing character of ANSI escape sequences.
// Required because URLs are matched before processing ANSI escape sequences.
// 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("(?<=m|\\b)https?://[-A-Za-z0-9+&@#/%?=~_|$!:,.;]*[A-Za-z0-9+&@#/%=~_|$]")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we don't use captured groups here, so we could add RegexOptions.ExplicitCapture to allocate less when matching, now that we've added a group.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at this regex more and I removed the group. I added flags to ignore case and culture.

public static partial Regex GenerateUrlRegEx();
}
18 changes: 17 additions & 1 deletion src/Aspire.Dashboard/wwwroot/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
};
13 changes: 13 additions & 0 deletions tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<span class=\"ansi-fg-cyan\"></span><a target=\"_blank\" href=\"https://www.example.com\">https://www.example.com</a>", entry.Content);
}
}
31 changes: 27 additions & 4 deletions tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
}
Expand All @@ -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 <a target=\"_blank\" href=\"https://bing.com/\">https://bing.com/</a> 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);
Expand All @@ -42,7 +43,7 @@ public void TryParse_ReturnsCorrectResult(string input, bool expectedResult, str
[InlineData("http://bing", "<a target=\"_blank\" href=\"http://bing\">http://bing</a>")]
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);
Expand All @@ -54,8 +55,30 @@ public void TryParse_SupportedUrlFormats(string input, string? expectedOutput)
[InlineData("ftp://user:[email protected]/")]
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</url>", "<a target=\"_blank\" href=\"http://localhost:8080\">http://localhost:8080</a>&lt;/url&gt;")]
[InlineData("http://localhost:8080\"", "<a target=\"_blank\" href=\"http://localhost:8080\">http://localhost:8080</a>&quot;")]
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("mhttp://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);
}
}