Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Time tooltips show full time precision #3202

Merged
merged 1 commit into from
Mar 27, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<div id="metric-table-container" style="height: 40vh; overflow-y: auto; margin-bottom: 20px; max-width:1200px;">
<FluentDataGrid Items="@_metricsView" ItemSize="35" Virtualize="true" GridTemplateColumns="@string.Join(" ", Enumerable.Repeat("1fr", columnCount))">
<ChildContent>
<TemplateColumn Title="@Loc[nameof(ControlsStrings.MetricTableStartColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, TimeProvider.ToLocal(context.DateTime), false, CultureInfo.CurrentCulture))" Tooltip="true">
<TemplateColumn Title="@Loc[nameof(ControlsStrings.MetricTableStartColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, TimeProvider.ToLocal(context.DateTime), MillisecondsDisplay.None, CultureInfo.CurrentCulture))" Tooltip="true">
@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, TimeProvider.ToLocal(context.DateTime))
</TemplateColumn>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
@((MarkupString)string.Format(ControlsStrings.StructuredLogsDetailsResource, ViewModel.LogEntry.Application.ApplicationName))
</div>
<FluentDivider Role="DividerRole.Presentation" Orientation="Orientation.Vertical" />
<div>
@((MarkupString)string.Format(ControlsStrings.StructuredLogsDetailsTimestamp, FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, ViewModel.LogEntry.TimeStamp, includeMilliseconds: true)))
<div title="@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, ViewModel.LogEntry.TimeStamp, MillisecondsDisplay.Full)">
@((MarkupString)string.Format(ControlsStrings.StructuredLogsDetailsTimestamp, FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, ViewModel.LogEntry.TimeStamp, MillisecondsDisplay.Truncated)))
</div>
<FluentSearch Placeholder="@Loc[nameof(ControlsStrings.FilterPlaceholder)]"
Immediate="true"
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Components/Pages/Resources.razor
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.Resources.ResourcesStateColumnHeader)]" Sortable="true" SortBy="@_stateSort">
<StateColumnDisplay Resource="@context" UnviewedErrorCounts ="@_applicationUnviewedErrorCounts" />
</TemplateColumn>
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.Resources.ResourcesStartTimeColumnHeader)]" Sortable="true" SortBy="@_startTimeSort" TooltipText="@(context => context.CreationTimeStamp != null ? FormatHelpers.FormatDateTime(TimeProvider, context.CreationTimeStamp.Value, false, CultureInfo.CurrentCulture) : null)" Tooltip="true">
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.Resources.ResourcesStartTimeColumnHeader)]" Sortable="true" SortBy="@_startTimeSort" TooltipText="@(context => context.CreationTimeStamp != null ? FormatHelpers.FormatDateTime(TimeProvider, context.CreationTimeStamp.Value, MillisecondsDisplay.None, CultureInfo.CurrentCulture) : null)" Tooltip="true">
<StartTimeColumnDisplay Resource="@context" />
</TemplateColumn>
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.Resources.ResourcesSourceColumnHeader)]">
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsLevelColumnHeader)]">
<LogLevelColumnDisplay LogEntry="@context" />
</TemplateColumn>
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsTimestampColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, context.TimeStamp, true, CultureInfo.CurrentCulture))" Tooltip="true">
@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, context.TimeStamp, includeMilliseconds: true)
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsTimestampColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, context.TimeStamp, MillisecondsDisplay.Full, CultureInfo.CurrentCulture))" Tooltip="true">
@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, context.TimeStamp, MillisecondsDisplay.Truncated)
</TemplateColumn>
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsMessageColumnHeader)]" Tooltip="true" TooltipText="(e) => e.Message">
<LogMessageColumnDisplay FilterText="@(ViewModel.FilterText)" LogEntry="@context" />
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
</div>
<FluentToolbar Orientation="Orientation.Horizontal">
<div>
@Loc[nameof(Dashboard.Resources.TraceDetail.TraceDetailTraceStartHeader)] <strong>@FormatHelpers.FormatDateTime(TimeProvider, _trace.FirstSpan.StartTime, includeMilliseconds: true)</strong>
@Loc[nameof(Dashboard.Resources.TraceDetail.TraceDetailTraceStartHeader)] <strong title="@FormatHelpers.FormatDateTime(TimeProvider, _trace.FirstSpan.StartTime, MillisecondsDisplay.Full)">@FormatHelpers.FormatDateTime(TimeProvider, _trace.FirstSpan.StartTime, MillisecondsDisplay.Truncated)</strong>
</div>
<FluentDivider Role="DividerRole.Presentation" Orientation="Orientation.Vertical" />
<div>
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Components/Pages/Traces.razor
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
<div class="datagrid-overflow-area continuous-scroll-overflow" tabindex="-1">
<FluentDataGrid Virtualize="true" GenerateHeader="GenerateHeaderOption.Sticky" ItemSize="46" ResizableColumns="true" ItemsProvider="@GetData" TGridItem="OtlpTrace" GridTemplateColumns="0.8fr 2fr 3fr 0.8fr 0.5fr">
<ChildContent>
<TemplateColumn Title="@ControlsStringsLoc[nameof(ControlsStrings.TimestampColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, context.FirstSpan.StartTime, true, CultureInfo.CurrentCulture))" Tooltip="true">
@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, context.FirstSpan.StartTime, includeMilliseconds: true)
<TemplateColumn Title="@ControlsStringsLoc[nameof(ControlsStrings.TimestampColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, context.FirstSpan.StartTime, MillisecondsDisplay.Full, CultureInfo.CurrentCulture))" Tooltip="true">
@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, context.FirstSpan.StartTime, MillisecondsDisplay.Truncated)
</TemplateColumn>
<TemplateColumn Title="@ControlsStringsLoc[nameof(ControlsStrings.NameColumnHeader)]" Tooltip="true" TooltipText="@(trace => GetNameTooltip(trace))">
<span><FluentHighlighter HighlightedText="@(TracesViewModel.FilterText)" Text="@(context.FullName)" /></span>
Expand Down
71 changes: 49 additions & 22 deletions src/Aspire.Dashboard/Utils/FormatHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@

namespace Aspire.Dashboard.Utils;

public enum MillisecondsDisplay
{
None,
Truncated,
Full
}

internal static partial class FormatHelpers
{
// There are an unbound number of CultureInfo instances so we don't want to use it as the key.
// Someone could have also customized their culture so we don't want to use the name as the key.
// This struct contains required information from the culture that is used in cached format strings.
private readonly record struct CultureDetailsKey(string LongTimePattern, string ShortDatePattern, string NumberDecimalSeparator);
private sealed record MillisecondFormatStrings(string LongTimePattern, string ShortDateLongTimePattern);
private sealed record MillisecondFormatStrings(MillisecondFormatString LongTimePattern, MillisecondFormatString ShortDateLongTimePattern);
private sealed record MillisecondFormatString(string TruncatedMilliseconds, string FullMilliseconds);
private static readonly ConcurrentDictionary<CultureDetailsKey, MillisecondFormatStrings> s_formatStrings = new();

[GeneratedRegex("(:ss|:s)")]
Expand All @@ -27,53 +35,72 @@ private static MillisecondFormatStrings GetMillisecondFormatStrings(CultureInfo

return s_formatStrings.GetOrAdd(key, static k =>
{
var longTimePatternWithMilliseconds = GetLongTimePatternWithMillisecondsCore(k);
return new MillisecondFormatStrings(longTimePatternWithMilliseconds, k.ShortDatePattern + " " + longTimePatternWithMilliseconds);
var (truncated, full) = GetLongTimePatternWithMillisecondsCore(k);
return new MillisecondFormatStrings(
new MillisecondFormatString(truncated, full),
new MillisecondFormatString(
k.ShortDatePattern + " " + truncated,
k.ShortDatePattern + " " + full));
});

static string GetLongTimePatternWithMillisecondsCore(CultureDetailsKey key)
static (string Truncated, string Full) GetLongTimePatternWithMillisecondsCore(CultureDetailsKey key)
{
// From https://learn.microsoft.com/dotnet/standard/base-types/how-to-display-milliseconds-in-date-and-time-values

// Gets the long time pattern, which is something like "h:mm:ss tt" (en-US), "H:mm:ss" (ja-JP), "HH:mm:ss" (fr-FR).
var longTimePattern = key.LongTimePattern;

// Create a format similar to .fff but based on the current culture.
// Intentionally use fff here instead of FFF so output has a consistent length.
var millisecondFormat = $"'{key.NumberDecimalSeparator}'fff";
var truncatedMillisecondFormat = "fff";

// Append millisecond pattern to current culture's long time pattern.
return MatchSecondsInTimeFormatPattern().Replace(longTimePattern, $"$1{millisecondFormat}");
return (
FormatPattern(key, truncatedMillisecondFormat),
FormatPattern(key, "FFFFFFF"));
}

static string FormatPattern(CultureDetailsKey key, string millisecondFormat)
{
// Gets the long time pattern, which is something like "h:mm:ss tt" (en-US), "H:mm:ss" (ja-JP), "HH:mm:ss" (fr-FR).
var longTimePattern = key.LongTimePattern;

return MatchSecondsInTimeFormatPattern().Replace(longTimePattern, $"$1'{key.NumberDecimalSeparator}'{millisecondFormat}");
}
}

private static string GetLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).LongTimePattern;
private static MillisecondFormatString GetLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).LongTimePattern;

private static string GetShortDateLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).ShortDateLongTimePattern;
private static MillisecondFormatString GetShortDateLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).ShortDateLongTimePattern;

public static string FormatTime(BrowserTimeProvider timeProvider, DateTime value, bool includeMilliseconds = false, CultureInfo? cultureInfo = null)
public static string FormatTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null)
{
cultureInfo ??= CultureInfo.CurrentCulture;
var local = timeProvider.ToLocal(value);

// Long time
return includeMilliseconds
? local.ToString(GetLongTimePatternWithMilliseconds(cultureInfo), cultureInfo)
: local.ToString("T", cultureInfo);
return millisecondsDisplay switch
{
MillisecondsDisplay.None => local.ToString("T", cultureInfo),
MillisecondsDisplay.Truncated => local.ToString(GetLongTimePatternWithMilliseconds(cultureInfo).TruncatedMilliseconds, cultureInfo),
MillisecondsDisplay.Full => local.ToString(GetLongTimePatternWithMilliseconds(cultureInfo).FullMilliseconds, cultureInfo),
_ => throw new NotImplementedException()
};
}

public static string FormatDateTime(BrowserTimeProvider timeProvider, DateTime value, bool includeMilliseconds = false, CultureInfo? cultureInfo = null)
public static string FormatDateTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null)
{
cultureInfo ??= CultureInfo.CurrentCulture;
var local = timeProvider.ToLocal(value);

// Short date, long time
return includeMilliseconds
? local.ToString(GetShortDateLongTimePatternWithMilliseconds(cultureInfo), cultureInfo)
: local.ToString("G", cultureInfo);
return millisecondsDisplay switch
{
MillisecondsDisplay.None => local.ToString("G", cultureInfo),
MillisecondsDisplay.Truncated => local.ToString(GetShortDateLongTimePatternWithMilliseconds(cultureInfo).TruncatedMilliseconds, cultureInfo),
MillisecondsDisplay.Full => local.ToString(GetShortDateLongTimePatternWithMilliseconds(cultureInfo).FullMilliseconds, cultureInfo),
_ => throw new NotImplementedException()
};
}

public static string FormatTimeWithOptionalDate(BrowserTimeProvider timeProvider, DateTime value, bool includeMilliseconds = false, CultureInfo? cultureInfo = null)
public static string FormatTimeWithOptionalDate(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null)
{
var local = timeProvider.ToLocal(value);

Expand All @@ -82,12 +109,12 @@ public static string FormatTimeWithOptionalDate(BrowserTimeProvider timeProvider
{
// e.g. "08:57:44" (based on user's culture and preferences)
// Don't include milliseconds as resource server returned time stamp is second precision.
return FormatTime(timeProvider, local, includeMilliseconds, cultureInfo);
return FormatTime(timeProvider, local, millisecondsDisplay, cultureInfo);
}
else
{
// e.g. "9/02/2024 08:57:44" (based on user's culture and preferences)
return FormatDateTime(timeProvider, local, includeMilliseconds, cultureInfo);
return FormatDateTime(timeProvider, local, millisecondsDisplay, cultureInfo);
}
}

Expand Down
33 changes: 18 additions & 15 deletions tests/Aspire.Dashboard.Tests/FormatHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,33 +34,36 @@ public void FormatNumberWithOptionalDecimalPlaces_GermanCulture(string expected,
}

[Theory]
[InlineData("06/15/2009 13:45:30.000", true, "2009-06-15T13:45:30.0000000Z")]
[InlineData("06/15/2009 13:45:30.123", true, "2009-06-15T13:45:30.1234567Z")]
[InlineData("06/15/2009 13:45:30", false, "2009-06-15T13:45:30.0000000Z")]
[InlineData("06/15/2009 13:45:30", false, "2009-06-15T13:45:30.1234567Z")]
public void FormatDateTime_WithMilliseconds_InvariantCulture(string expected, bool includeMilliseconds, string value)
[InlineData("06/15/2009 13:45:30.000", MillisecondsDisplay.Truncated, "2009-06-15T13:45:30.0000000Z")]
[InlineData("06/15/2009 13:45:30.123", MillisecondsDisplay.Truncated, "2009-06-15T13:45:30.1234567Z")]
[InlineData("06/15/2009 13:45:30.1234567", MillisecondsDisplay.Full, "2009-06-15T13:45:30.1234567Z")]
[InlineData("06/15/2009 13:45:30", MillisecondsDisplay.None, "2009-06-15T13:45:30.0000000Z")]
[InlineData("06/15/2009 13:45:30", MillisecondsDisplay.None, "2009-06-15T13:45:30.1234567Z")]
public void FormatDateTime_WithMilliseconds_InvariantCulture(string expected, MillisecondsDisplay includeMilliseconds, string value)
{
var date = GetLocalDateTime(value);
Assert.Equal(expected, FormatHelpers.FormatDateTime(CreateTimeProvider(), date, includeMilliseconds, cultureInfo: CultureInfo.InvariantCulture));
}

[Theory]
[InlineData("15.06.2009 13:45:30,000", true, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15.06.2009 13:45:30,123", true, "2009-06-15T13:45:30.1234567Z")]
[InlineData("15.06.2009 13:45:30", false, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15.06.2009 13:45:30", false, "2009-06-15T13:45:30.1234567Z")]
public void FormatDateTime_WithMilliseconds_GermanCulture(string expected, bool includeMilliseconds, string value)
[InlineData("15.06.2009 13:45:30,000", MillisecondsDisplay.Truncated, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15.06.2009 13:45:30,123", MillisecondsDisplay.Truncated, "2009-06-15T13:45:30.1234567Z")]
[InlineData("15.06.2009 13:45:30,1234567", MillisecondsDisplay.Full, "2009-06-15T13:45:30.1234567Z")]
[InlineData("15.06.2009 13:45:30", MillisecondsDisplay.None, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15.06.2009 13:45:30", MillisecondsDisplay.None, "2009-06-15T13:45:30.1234567Z")]
public void FormatDateTime_WithMilliseconds_GermanCulture(string expected, MillisecondsDisplay includeMilliseconds, string value)
{
var date = GetLocalDateTime(value);
Assert.Equal(expected, FormatHelpers.FormatDateTime(CreateTimeProvider(), date, includeMilliseconds, cultureInfo: CultureInfo.GetCultureInfo("de-DE")));
}

[Theory]
[InlineData("15/06/2009 1:45:30.000 pm", true, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15/06/2009 1:45:30.123 pm", true, "2009-06-15T13:45:30.1234567Z")]
[InlineData("15/06/2009 1:45:30 pm", false, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15/06/2009 1:45:30 pm", false, "2009-06-15T13:45:30.1234567Z")]
public void FormatDateTime_WithMilliseconds_NewZealandCulture(string expected, bool includeMilliseconds, string value)
[InlineData("15/06/2009 1:45:30.000 pm", MillisecondsDisplay.Truncated, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15/06/2009 1:45:30.123 pm", MillisecondsDisplay.Truncated, "2009-06-15T13:45:30.1234567Z")]
[InlineData("15/06/2009 1:45:30.1234567 pm", MillisecondsDisplay.Full, "2009-06-15T13:45:30.1234567Z")]
[InlineData("15/06/2009 1:45:30 pm", MillisecondsDisplay.None, "2009-06-15T13:45:30.0000000Z")]
[InlineData("15/06/2009 1:45:30 pm", MillisecondsDisplay.None, "2009-06-15T13:45:30.1234567Z")]
public void FormatDateTime_WithMilliseconds_NewZealandCulture(string expected, MillisecondsDisplay includeMilliseconds, string value)
{
var date = GetLocalDateTime(value);
Assert.Equal(expected, FormatHelpers.FormatDateTime(CreateTimeProvider(), date, includeMilliseconds, cultureInfo: CultureInfo.GetCultureInfo("en-NZ")));
Expand Down