diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index d0aaef307e0..4a71e6ca7d9 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -261,6 +261,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 0bad5be92c5..033b67da828 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -46,6 +46,9 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable [Inject] public required TelemetryRepository TelemetryRepository { get; init; } + [Inject] + public required PauseManager PauseManager { get; init; } + [Parameter, EditorRequired] public required InstrumentViewModel InstrumentViewModel { get; set; } @@ -66,7 +69,7 @@ protected override void OnInitialized() { // Copy the token so there is no chance it is accessed on CTS after it is disposed. CancellationToken = _cts.Token; - _currentDataStartTime = GetCurrentDataTime(); + _currentDataStartTime = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : GetCurrentDataTime(); InstrumentViewModel.DataUpdateSubscriptions.Add(OnInstrumentDataUpdate); } @@ -80,7 +83,7 @@ InstrumentViewModel.MatchedDimensions is null || return; } - var inProgressDataTime = GetCurrentDataTime(); + var inProgressDataTime = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : GetCurrentDataTime(); while (_currentDataStartTime.Add(_tickDuration) < inProgressDataTime) { diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index c9567da79b2..756ab8c26d5 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -17,12 +17,12 @@ else

@_instrument.Summary.Description

@if (_instrument.HasOverflow) { -
-
+
+
-
+
@Loc[nameof(ControlsStrings.ChartContainerOverflowTitle)] @Loc[nameof(ControlsStrings.ChartContainerOverflowDescription)]
@((MarkupString)string.Format(ControlsStrings.ChartContainerOverflowLink, "https://aka.ms/dotnet/aspire/cardinality-limits")) @@ -53,14 +53,3 @@ else
} - -@code { - [Parameter, EditorRequired] - public required Metrics.MetricViewKind ActiveView { get; set; } - - [Parameter, EditorRequired] - public required Func OnViewChangedAsync { get; set; } - - [Parameter] - public required List Applications { get; set; } -} diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 5a97c3d63ff..474fc5a2bf8 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -34,6 +34,15 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable [Parameter, EditorRequired] public required TimeSpan Duration { get; set; } + [Parameter, EditorRequired] + public required Pages.Metrics.MetricViewKind ActiveView { get; set; } + + [Parameter, EditorRequired] + public required Func OnViewChangedAsync { get; set; } + + [Parameter, EditorRequired] + public required List Applications { get; set; } + [Inject] public required TelemetryRepository TelemetryRepository { get; init; } @@ -43,6 +52,9 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable [Inject] public required ThemeManager ThemeManager { get; init; } + [Inject] + public required PauseManager PauseManager { get; init; } + public ImmutableList DimensionFilters { get; set; } = []; public string? PreviousMeterName { get; set; } public string? PreviousInstrumentName { get; set; } @@ -79,7 +91,7 @@ private async Task UpdateDataAsync() while (await timer!.WaitForNextTickAsync()) { _instrument = GetInstrument(); - if (_instrument == null) + if (_instrument == null || PauseManager.AreMetricsPaused(out _)) { continue; } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.css b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.css index 32f2bbb35ce..3ad3007eeca 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.css @@ -5,38 +5,3 @@ ::deep .metric-tab { margin-top: 10px; } - -.overflow-warning { - font-family: var(--body-font); - border: 1px solid var(--messagebar-warning-border-color); - background-color: var(--messagebar-warning-background-color); - color: var(--neutral-foreground-rest); - display: grid; - grid-template-columns: 24px auto; - width: fit-content; - align-items: center; - min-height: 36px; - border-radius: calc(var(--control-corner-radius)* 1px); - padding: 0 12px; - column-gap: 8px; -} - -.overflow-warning-icon { - grid-column: 1; - display: flex; - justify-content: center; -} - -.overflow-warning-message { - grid-column: 2; - padding: 10px 0; - align-self: center; - font-size: 12px; - font-weight: 400; - line-height: 16px; -} - -.overflow-warning-message .title { - font-weight: 600; - padding: 0 4px 0 0; -} diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor index 998e93d7e10..47843d590af 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor @@ -1,9 +1,12 @@ -@namespace Aspire.Dashboard.Components.Controls -@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Resources +@namespace Aspire.Dashboard.Components.Controls + +@inject IStringLocalizer Loc + ButtonClass="clear-button" + Title="@Loc[nameof(ControlsStrings.ClearSignalsButtonTitle)]" /> diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor index 0680366ef51..18f0353ebdf 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor @@ -1,39 +1,75 @@ @namespace Aspire.Dashboard.Components @using System.Globalization -@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Resources @using Aspire.Dashboard.Utils @using Aspire.Hosting.ConsoleLogs + @inject IJSRuntime JS +@inject IStringLocalizer Loc + @implements IAsyncDisposable
- @if (LogEntries is {} logEntries) + @if (LogEntries is { } logEntries) { - -
-
- - @context.LineNumber - - @{ - var hasPrefix = false; - } - @if (ShowTimestamp && context.Timestamp is { } timestamp) - { - hasPrefix = true; - @GetDisplayTimestamp(timestamp) - } - @if (context.Type == LogEntryType.Error) - { - hasPrefix = true; - stderr - } - @((MarkupString)((hasPrefix ? " " : string.Empty) + (context.Content ?? string.Empty))) + + @if (context.Pause is { } pause) + { + // If this is a previous pause but no logs were obtained during the pause, we don't need to show anything. + if (pause is { FilteredCount: 0, EndTime: not null }) + { + return; + } + + var text = pause.EndTime is null + ? string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(ConsoleLogs.ConsoleLogsPauseActive)], + FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, pause.StartTime, MillisecondsDisplay.Truncated), + pause.FilteredCount) + : string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(ConsoleLogs.ConsoleLogsPauseDetails)], + FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, pause.StartTime, MillisecondsDisplay.Truncated), + FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, pause.EndTime.Value, MillisecondsDisplay.Truncated), + pause.FilteredCount); + +
+
+
+ + @text +
+
+
+ } + else + { +
+
+ + @context.LineNumber + + @{ + var hasPrefix = false; + } + @if (ShowTimestamp && context.Timestamp is { } timestamp) + { + hasPrefix = true; + @GetDisplayTimestamp(timestamp) + } + @if (context.Type == LogEntryType.Error) + { + hasPrefix = true; + stderr + } + @((MarkupString)((hasPrefix ? " " : string.Empty) + (context.Content ?? string.Empty))) + - +
-
+ } }
diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs index fcc9a94167c..bddc9331902 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs @@ -27,6 +27,9 @@ public sealed partial class LogViewer [Inject] public required ILogger Logger { get; init; } + [Inject] + public required PauseManager PauseManager { get; init; } + [Parameter] public LogEntries? LogEntries { get; set; } = null!; @@ -36,6 +39,9 @@ public sealed partial class LogViewer [Parameter] public bool IsTimestampUtc { get; set; } + [Parameter] + public string? ApplicationName { get; set; } + protected override void OnParametersSet() { if (_logEntries != LogEntries) diff --git a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor new file mode 100644 index 00000000000..de03ac0948d --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor @@ -0,0 +1,15 @@ +@using Aspire.Dashboard.Resources +@inject IStringLocalizer Loc + + + @if (IsPaused) + { + + } + else + { + + } + diff --git a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs new file mode 100644 index 00000000000..3d04ab4ff97 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Controls; + +public partial class PauseIncomingDataSwitch : ComponentBase +{ + [Parameter] + public bool IsPaused { get; set; } + + [Parameter] + public EventCallback IsPausedChanged { get; set; } + + private async Task OnTogglePauseCoreAsync() + { + IsPaused = !IsPaused; + await IsPausedChanged.InvokeAsync(IsPaused); + } +} + diff --git a/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor b/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor index 6f782c4a4b7..a66beeea69b 100644 --- a/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor +++ b/src/Aspire.Dashboard/Components/Controls/TotalItemsFooter.razor @@ -2,7 +2,25 @@ @namespace Aspire.Dashboard.Components @inject IStringLocalizer Loc - + @code { // Total item count can be set via the parameter or via method. @@ -15,6 +33,9 @@ [Parameter] public int TotalItemCount { get; set; } + [Parameter] + public string? PauseText { get; set; } + /// /// Called when data grid data is refreshed. This sets the count explicitly and forces the control to re-render. /// diff --git a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor index cb9ff8d1352..e1bdd743cdc 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor @@ -48,7 +48,7 @@ {
- +
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index e0ca2f3f807..c0fb9944114 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -27,24 +27,30 @@ @bind-SelectedResource:after="HandleSelectedOptionChangedAsync" LabelClass="toolbar-left" /> + - @foreach (var command in _highlightedCommands) + @if (_highlightedCommands.Count > 0) { - - @if (!string.IsNullOrEmpty(command.IconName) && CommandViewModel.ResolveIconName(command.IconName, command.IconVariant) is { } icon) - { - - } - else - { - @command.DisplayName - } - + @Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsResourceCommands)] + + @foreach (var command in _highlightedCommands) + { + + @if (!string.IsNullOrEmpty(command.IconName) && CommandViewModel.ResolveIconName(command.IconName, command.IconVariant) is { } icon) + { + + } + else + { + @command.DisplayName + } + + } } @if (_resourceMenuItems.Count > 0) @@ -82,7 +88,11 @@ - +
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 45070adc293..f9c5355c01d 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -77,6 +77,9 @@ private sealed class ConsoleLogsSubscription [Inject] public required BrowserTimeProvider TimeProvider { get; init; } + [Inject] + public required PauseManager PauseManager { get; init; } + [CascadingParameter] public required ViewportInformation ViewportInformation { get; init; } @@ -90,6 +93,7 @@ private sealed class ConsoleLogsSubscription private Task? _resourceSubscriptionTask; private ConsoleLogsSubscription? _consoleLogsSubscription; internal LogEntries _logEntries = null!; + private readonly object _updateLogsLock = new object(); // UI private SelectViewModel _noSelection = null!; @@ -117,8 +121,12 @@ protected override async Task OnInitializedAsync() _consoleLogsFiltersChangedSubscription = ConsoleLogsManager.OnFiltersChanged(async () => { - _consoleLogFilters = ConsoleLogsManager.Filters; - _logEntries.Clear(); + lock (_updateLogsLock) + { + _consoleLogFilters = ConsoleLogsManager.Filters; + _logEntries.Clear(keepActivePauseEntries: true); + } + await InvokeAsync(StateHasChanged); }); @@ -446,23 +454,17 @@ private void LoadLogs(ConsoleLogsSubscription newConsoleLogsSubscription) try { - // Console logs are filtered in the UI by the timestamp of the log entry. - DateTime? timestampFilterDate; - - if (PageViewModel.SelectedOption.Id is not null && - _consoleLogFilters.FilterResourceLogsDates.TryGetValue( - PageViewModel.SelectedOption.Id.GetApplicationKey().ToString(), - out var filterResourceLogsDate)) - { - // There is a filter for this individual resource. - timestampFilterDate = filterResourceLogsDate; - } - else + lock (_updateLogsLock) { - // Fallback to the global filter (if any, it could be null). - timestampFilterDate = _consoleLogFilters.FilterAllLogsDate; + foreach (var priorPause in PauseManager.ConsoleLogPauseIntervals) + { + _logEntries.InsertSorted(LogEntry.CreatePause(priorPause.Start, priorPause.End)); + } } + // Console logs are filtered in the UI by the timestamp of the log entry. + var timestampFilterDate = GetFilteredDateFromRemove(); + var logParser = new LogParser(); await foreach (var batch in subscription.ConfigureAwait(true)) { @@ -471,18 +473,27 @@ private void LoadLogs(ConsoleLogsSubscription newConsoleLogsSubscription) continue; } - foreach (var (lineNumber, content, isErrorOutput) in batch) + lock (_updateLogsLock) { - // Set the base line number using the reported line number of the first log line. - if (_logEntries.EntriesCount == 0) + foreach (var (lineNumber, content, isErrorOutput) in batch) { - _logEntries.BaseLineNumber = lineNumber; - } + // Set the base line number using the reported line number of the first log line. + _logEntries.BaseLineNumber ??= lineNumber; + + var logEntry = logParser.CreateLogEntry(content, isErrorOutput); + + // Check if log entry is not displayed because of remove. + if (logEntry.Timestamp is not null && timestampFilterDate is not null && !(logEntry.Timestamp > timestampFilterDate)) + { + continue; + } + + // Check if log entry is not displayed because of pause. + if (_logEntries.ProcessPauseFilters(logEntry)) + { + continue; + } - var logEntry = logParser.CreateLogEntry(content, isErrorOutput); - if (timestampFilterDate is null || logEntry.Timestamp is null || logEntry.Timestamp > timestampFilterDate) - { - // Only add entries that are not ignored, or if they are null as we cannot know when they happened. _logEntries.InsertSorted(logEntry); } } @@ -507,6 +518,27 @@ private void LoadLogs(ConsoleLogsSubscription newConsoleLogsSubscription) newConsoleLogsSubscription.SubscriptionTask = consoleLogsTask; } + private DateTime? GetFilteredDateFromRemove() + { + DateTime? timestampFilterDate; + + if (PageViewModel.SelectedOption.Id is not null && + _consoleLogFilters.FilterResourceLogsDates.TryGetValue( + PageViewModel.SelectedOption.Id.GetApplicationKey().ToString(), + out var filterResourceLogsDate)) + { + // There is a filter for this individual resource. + timestampFilterDate = filterResourceLogsDate; + } + else + { + // Fallback to the global filter (if any, it could be null). + timestampFilterDate = _consoleLogFilters.FilterAllLogsDate; + } + + return timestampFilterDate; + } + private async Task HandleSelectedOptionChangedAsync() { PageViewModel.SelectedResource = PageViewModel.SelectedOption?.Id?.InstanceId is null ? null : _resourceByName[PageViewModel.SelectedOption.Id.InstanceId]; @@ -560,6 +592,11 @@ private async Task DownloadLogsAsync() { foreach (var entry in _logEntries.GetEntries()) { + if (entry.Type is LogEntryType.Pause) + { + continue; + } + // It's ok to use sync stream methods here because we're writing to a MemoryStream. if (entry.RawContent is not null) { @@ -600,6 +637,29 @@ private async Task ClearConsoleLogs(ApplicationKey? key) await ConsoleLogsManager.UpdateFiltersAsync(_consoleLogFilters); } + private void OnPausedChanged(bool isPaused) + { + var timestamp = DateTime.UtcNow; + PauseManager.SetConsoleLogsPaused(isPaused, timestamp); + + if (PageViewModel.SelectedResource != null) + { + lock (_updateLogsLock) + { + if (isPaused) + { + _logEntries.InsertSorted(LogEntry.CreatePause(timestamp)); + } + else + { + var pause = _logEntries.GetEntries().Last().Pause; + Debug.Assert(pause is not null); + pause.EndTime = timestamp; + } + } + } + } + public async ValueTask DisposeAsync() { _consoleLogsFiltersChangedSubscription?.Dispose(); diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor b/src/Aspire.Dashboard/Components/Pages/Metrics.razor index bddb1c6146c..6600e1e6c6e 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor @@ -48,6 +48,7 @@ CanSelectGrouping="true" LabelClass="toolbar-left" /> + @@ -98,7 +99,7 @@
- @if (PageViewModel.SelectedApplication.Id?.ReplicaSetName != null && PageViewModel.SelectedMeter != null && PageViewModel.SelectedInstrument != null) + @if (PageViewModel.SelectedApplication.Id?.ReplicaSetName != null && PageViewModel is { SelectedMeter: not null, SelectedInstrument: not null }) { Logger { get; init; } + [Inject] + public required PauseManager PauseManager { get; init; } + [CascadingParameter] public required ViewportInformation ViewportInformation { get; init; } diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor index 83709331680..25c08f33069 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor @@ -38,6 +38,7 @@ CanSelectGrouping="true" LabelClass="toolbar-left" /> + @@ -184,9 +185,11 @@ - - +
diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index aca17fd1365..75fec5127dd 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -82,6 +82,9 @@ public partial class StructuredLogs : IPageWithSessionAndUrlState PauseManager.AreStructuredLogsPaused(out var startTime) + ? string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(Dashboard.Resources.StructuredLogs.PauseInProgressText)], + FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, startTime.Value, MillisecondsDisplay.Truncated)) + : null; + public void Dispose() { _applicationsSubscription?.Dispose(); diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.css b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.css index 808bae06dab..968c2ed381a 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.css @@ -25,7 +25,6 @@ grid-template-areas: "main" "foot"; - gap: calc(var(--design-unit) * 2px); } ::deep .logs-summary-layout > .logs-grid-container { diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor b/src/Aspire.Dashboard/Components/Pages/Traces.razor index 7984a0c462d..c348046a227 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor @@ -36,6 +36,7 @@ CanSelectGrouping="true" LabelClass="toolbar-left" /> + @@ -169,7 +170,10 @@
- +
diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index d7ab137de73..09b480d2396 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -81,6 +81,9 @@ public partial class Traces : IPageWithSessionAndUrlState PauseManager.AreTracesPaused(out var startTime) + ? string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(Dashboard.Resources.StructuredLogs.PauseInProgressText)], + FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, startTime.Value, MillisecondsDisplay.Truncated)) + : null; + public void Dispose() { _applicationsSubscription?.Dispose(); diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 9a28043a224..0bb1c01bae9 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -230,6 +230,8 @@ public DashboardWebApplication( builder.Services.TryAddSingleton(); builder.Services.TryAddScoped(); + builder.Services.AddSingleton(); + // OTLP services. builder.Services.AddGrpc(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Dashboard/Model/PauseManager.cs b/src/Aspire.Dashboard/Model/PauseManager.cs new file mode 100644 index 00000000000..50834e8c964 --- /dev/null +++ b/src/Aspire.Dashboard/Model/PauseManager.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Dashboard.Model; + +public sealed class PauseManager +{ + private DateTime? _metricsPausedAt; + private DateTime? _tracesPausedAt; + private DateTime? _structuredLogsPausedAt; + private ImmutableArray _consoleLogPauseIntervals = ImmutableArray.Empty; + + public bool ConsoleLogsPaused { get; private set; } + + public ImmutableArray ConsoleLogPauseIntervals + { + get => _consoleLogPauseIntervals; + private set => _consoleLogPauseIntervals = value; + } + + public void SetMetricsPaused(bool isPaused) => _metricsPausedAt = isPaused ? DateTime.UtcNow : null; + + public bool AreMetricsPaused([NotNullWhen(true)] out DateTime? pausedAt) + { + pausedAt = _metricsPausedAt; + return _metricsPausedAt is not null; + } + + public void SetTracesPaused(bool isPaused) => _tracesPausedAt = isPaused ? DateTime.UtcNow : null; + + public bool AreTracesPaused([NotNullWhen(true)] out DateTime? pausedAt) + { + pausedAt = _tracesPausedAt; + return _tracesPausedAt is not null; + } + + public void SetStructuredLogsPaused(bool isPaused) => _structuredLogsPausedAt = isPaused ? DateTime.UtcNow : null; + + public bool AreStructuredLogsPaused([NotNullWhen(true)] out DateTime? pausedAt) + { + pausedAt = _structuredLogsPausedAt; + return _structuredLogsPausedAt is not null; + } + + public void SetConsoleLogsPaused(bool isPaused, DateTime timestamp) + { + ConsoleLogsPaused = isPaused; + + ImmutableInterlocked.Update(ref _consoleLogPauseIntervals, intervals => + { + if (isPaused) + { + var newInterval = new PauseInterval(timestamp, null); + return intervals.Add(newInterval); + } + else + { + Debug.Assert(intervals.Length > 0, "There should be at least one interval."); + var lastInterval = intervals.Last(); + Debug.Assert(lastInterval.End is null, "The last interval should not have an end time if unpausing."); + var updatedInterval = lastInterval with { End = timestamp }; + return intervals.SetItem(intervals.Length - 1, updatedInterval); + } + }); + } +} + +public sealed record PauseInterval(DateTime Start, DateTime? End); diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index 7cd09dd96f4..9c5045d3ceb 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -8,6 +8,7 @@ using System.Runtime.InteropServices; using System.Text; using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; using Google.Protobuf.Collections; @@ -24,6 +25,9 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed class TelemetryRepository { + private readonly PauseManager _pauseManager; + private readonly ILogger _logger; + private readonly object _lock = new(); internal TimeSpan _subscriptionMinExecuteInterval = TimeSpan.FromMilliseconds(100); @@ -57,14 +61,15 @@ public sealed class TelemetryRepository internal List SpanLinks => _spanLinks; internal List TracesSubscriptions => _tracesSubscriptions; - public TelemetryRepository(ILoggerFactory loggerFactory, IOptions dashboardOptions) + public TelemetryRepository(ILoggerFactory loggerFactory, IOptions dashboardOptions, PauseManager pauseManager) { - var logger = loggerFactory.CreateLogger(typeof(TelemetryRepository)); + _logger = loggerFactory.CreateLogger(typeof(TelemetryRepository)); _otlpContext = new OtlpContext { - Logger = logger, + Logger = _logger, Options = dashboardOptions.Value.TelemetryLimits }; + _pauseManager = pauseManager; _logs = new(_otlpContext.Options.MaxLogCount); _traces = new(_otlpContext.Options.MaxTraceCount); @@ -271,6 +276,12 @@ private void RaiseSubscriptionChanged(List subscriptions) public void AddLogs(AddContext context, RepeatedField resourceLogs) { + if (_pauseManager.AreStructuredLogsPaused(out _)) + { + _logger.LogTrace("{Count} incoming structured log(s) ignored because of an active pause.", resourceLogs.Count); + return; + } + foreach (var rl in resourceLogs) { OtlpApplicationView applicationView; @@ -804,6 +815,12 @@ public Dictionary GetLogsFieldValues(string attributeName) public void AddMetrics(AddContext context, RepeatedField resourceMetrics) { + if (_pauseManager.AreMetricsPaused(out _)) + { + _logger.LogTrace("{Count} incoming metric(s) ignored because of an active pause.", resourceMetrics.Count); + return; + } + foreach (var rm in resourceMetrics) { OtlpApplicationView applicationView; @@ -826,6 +843,12 @@ public void AddMetrics(AddContext context, RepeatedField resour public void AddTraces(AddContext context, RepeatedField resourceSpans) { + if (_pauseManager.AreTracesPaused(out _)) + { + _logger.LogTrace("{Count} incoming trace(s) ignored because of an active pause.", resourceSpans.Count); + return; + } + foreach (var rs in resourceSpans) { OtlpApplicationView applicationView; diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index c94c8871dc0..230345f3056 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -131,6 +132,24 @@ public static string ConsoleLogsPageTitle { } } + /// + /// Looks up a localized string similar to <Log capture paused at {0}, {1} log(s) filtered out>. + /// + public static string ConsoleLogsPauseActive { + get { + return ResourceManager.GetString("ConsoleLogsPauseActive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to <Log capture paused between {0} and {1}, {2} log(s) filtered out>. + /// + public static string ConsoleLogsPauseDetails { + get { + return ResourceManager.GetString("ConsoleLogsPauseDetails", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resource commands. /// diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index aa6b44339d9..0560ea925fb 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -1,17 +1,17 @@ - @@ -172,4 +172,12 @@ UTC timestamps - + + <Log capture paused at {0}, {1} log(s) filtered out> + {0} is a date, {1} is a number + + + <Log capture paused between {0} and {1}, {2} log(s) filtered out> + {0} is a date, {1} is a date, {2} is a number. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 70a86c0845e..47f7cc04403 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -249,6 +249,15 @@ public static string ClearSelectedResource { } } + /// + /// Looks up a localized string similar to Remove data. + /// + public static string ClearSignalsButtonTitle { + get { + return ResourceManager.GetString("ClearSignalsButtonTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Details. /// @@ -618,6 +627,15 @@ public static string PageToolbarLandmark { } } + /// + /// Looks up a localized string similar to Pause incoming data. + /// + public static string PauseButtonTitle { + get { + return ResourceManager.GetString("PauseButtonTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Count. /// @@ -780,6 +798,15 @@ public static string ResourceLabel { } } + /// + /// Looks up a localized string similar to Resume incoming data. + /// + public static string ResumeButtonTitle { + get { + return ResourceManager.GetString("ResumeButtonTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Select an application. /// @@ -1033,7 +1060,16 @@ public static string TimestampColumnHeader { } /// - /// Looks up a localized string similar to Total: <strong>{0} results found</strong>. + /// Looks up a localized string similar to Capture paused. + /// + public static string TotalItemsFooterCapturePaused { + get { + return ResourceManager.GetString("TotalItemsFooterCapturePaused", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Total: <strong>{0} result(s) found</strong>. /// public static string TotalItemsFooterText { get { diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index 8faa009639c..311963fc2a2 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -212,7 +212,7 @@ Close - Total: <strong>{0} results found</strong> + Total: <strong>{0} result(s) found</strong> {0} is a number. This is raw markup, so <strong> and </strong> should not be modified @@ -472,4 +472,16 @@ No endpoints + + Pause incoming data + + + Resume incoming data + + + Remove data + + + Capture paused + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/StructuredLogs.Designer.cs b/src/Aspire.Dashboard/Resources/StructuredLogs.Designer.cs index 186204e28f0..d4a0a2059d1 100644 --- a/src/Aspire.Dashboard/Resources/StructuredLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/StructuredLogs.Designer.cs @@ -87,6 +87,15 @@ public static string MessageExceededLimitTitle { } } + /// + /// Looks up a localized string similar to Structured logs capture paused at {0}. + /// + public static string PauseInProgressText { + get { + return ResourceManager.GetString("PauseInProgressText", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add filter. /// diff --git a/src/Aspire.Dashboard/Resources/StructuredLogs.resx b/src/Aspire.Dashboard/Resources/StructuredLogs.resx index 3296265ec94..1405c418e6e 100644 --- a/src/Aspire.Dashboard/Resources/StructuredLogs.resx +++ b/src/Aspire.Dashboard/Resources/StructuredLogs.resx @@ -1,4 +1,4 @@ - +