Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 4 additions & 4 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
else if (_formattedValue != null)
{
@Value
@((MarkupString)_formattedValue)
}
@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: 0 additions & 27 deletions src/Aspire.Dashboard/ConsoleLogs/LogLevelParser.cs

This file was deleted.

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
35 changes: 25 additions & 10 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,30 @@ 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
// 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();
}
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);
}
}

This file was deleted.

Loading
Loading