diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor index 26d80cd94c..7c4881dda7 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor @@ -39,7 +39,7 @@ { "title", item.Tooltip ?? string.Empty } }; - + @item.Text @if (item.Icon != null) { diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs index 52e38fac71..3463d32d5a 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs @@ -40,7 +40,8 @@ public partial class AspireMenuButton : FluentComponentBase [Parameter] public string? Title { get; set; } - public string MenuButtonId { get; } = Identifier.NewId(); + [Parameter] + public string MenuButtonId { get; set; } = Identifier.NewId(); protected override void OnParametersSet() { diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs index ddce0fb5aa..9c1329cd0c 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs @@ -35,6 +35,7 @@ protected override void OnParametersSet() _clearMenuItems.Add(new() { + Id = "clear-menu-all", Icon = s_clearAllResourcesIcon, OnClick = () => HandleClearSignal(null), Text = ControlsStringsLoc[name: nameof(ControlsStrings.ClearAllResources)], @@ -42,6 +43,7 @@ protected override void OnParametersSet() _clearMenuItems.Add(new() { + Id = "clear-menu-resource", Icon = s_clearSelectedResourceIcon, OnClick = () => HandleClearSignal(SelectedResource.Id?.GetApplicationKey()), IsDisabled = SelectedResource.Id == null, diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index fbb7c8b7de..bedc48c15a 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -27,6 +27,8 @@ @bind-SelectedResource:after="HandleSelectedOptionChangedAsync" LabelClass="toolbar-left" /> + + @foreach (var command in _highlightedCommands) { DashboardUrls.ConsoleLogBasePath; public string SessionStorageKey => BrowserStorageKeys.ConsoleLogsPageState; @@ -111,6 +117,12 @@ protected override async Task OnInitializedAsync() _showTimestamp = showTimestamp; } + var filtersResult = await SessionStorage.GetAsync(BrowserStorageKeys.ConsoleLogFilters); + if (filtersResult.Value is { } filters) + { + _consoleLogFilters = filters; + } + var loadingTcs = new TaskCompletionSource(); await TrackResourceSnapshotsAsync(); @@ -417,6 +429,23 @@ 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 + { + // Fallback to the global filter (if any, it could be null). + timestampFilterDate = _consoleLogFilters.FilterAllLogsDate; + } + var logParser = new LogParser(); await foreach (var batch in subscription.ConfigureAwait(true)) { @@ -434,7 +463,11 @@ private void LoadLogs(ConsoleLogsSubscription newConsoleLogsSubscription) } var logEntry = logParser.CreateLogEntry(content, isErrorOutput); - _logEntries.InsertSorted(logEntry); + 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); + } } await InvokeAsync(StateHasChanged); @@ -526,11 +559,33 @@ private async Task DownloadLogsAsync() using var streamReference = new DotNetStreamReference(stream); var safeDisplayName = string.Join("_", PageViewModel.SelectedResource!.DisplayName.Split(Path.GetInvalidFileNameChars())); - var fileName = $"{safeDisplayName}-{DateTime.Now.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture)}.txt"; + var fileName = $"{safeDisplayName}-{TimeProvider.GetLocalNow().ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture)}.txt"; await JS.InvokeVoidAsync("downloadStreamAsFile", fileName, streamReference); } + private async Task ClearConsoleLogs(ApplicationKey? key) + { + var now = TimeProvider.GetUtcNow().UtcDateTime; + if (key is null) + { + _consoleLogFilters.FilterAllLogsDate = now; + _consoleLogFilters.FilterResourceLogsDates?.Clear(); + } + else + { + _consoleLogFilters.FilterResourceLogsDates ??= []; + _consoleLogFilters.FilterResourceLogsDates[key.Value.ToString()] = now; + } + + // Save filters to session storage so they're persisted when navigating to and from the console logs page. + // This makes remove behavior persistant which matches removing telemetry. + await SessionStorage.SetAsync(BrowserStorageKeys.ConsoleLogFilters, _consoleLogFilters); + + _logEntries.Clear(); + StateHasChanged(); + } + public async ValueTask DisposeAsync() { _resourceSubscriptionCts.Cancel(); @@ -551,6 +606,12 @@ public record ConsoleLogsPageState(string? SelectedResource); public record ConsoleLogConsoleSettings(bool ShowTimestamp); + public class ConsoleLogFilters + { + public DateTime? FilterAllLogsDate { get; set; } + public Dictionary FilterResourceLogsDates { get; set; } = []; + } + public Task UpdateViewModelFromQueryAsync(ConsoleLogsViewModel viewModel) { if (_resources is not null && ResourceName is not null) diff --git a/src/Aspire.Dashboard/Model/MenuButtonItem.cs b/src/Aspire.Dashboard/Model/MenuButtonItem.cs index 72f86c1499..174e836636 100644 --- a/src/Aspire.Dashboard/Model/MenuButtonItem.cs +++ b/src/Aspire.Dashboard/Model/MenuButtonItem.cs @@ -13,5 +13,6 @@ public class MenuButtonItem public Icon? Icon { get; set; } public Func? OnClick { get; set; } public bool IsDisabled { get; set; } + public string Id { get; set; } = Identifier.NewId(); public IReadOnlyDictionary? AdditionalAttributes { get; set; } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs b/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs index 816bd02071..26b58b8167 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs @@ -53,4 +53,14 @@ public bool EqualsCompositeName(string name) return true; } + + public override string ToString() + { + if (InstanceId == null) + { + return Name; + } + + return $"{Name}-{InstanceId}"; + } } diff --git a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs index 1d5e8eeaea..3b638c4e45 100644 --- a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs +++ b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs @@ -13,7 +13,8 @@ internal static class BrowserStorageKeys public const string StructuredLogsPageState = "Aspire_PageState_StructuredLogs"; public const string MetricsPageState = "Aspire_PageState_Metrics"; public const string ConsoleLogsPageState = "Aspire_PageState_ConsoleLogs"; - public const string ConsoleLogConsoleSettings = "Aspire_ConsoleLog_ConsoleSettings"; + public const string ConsoleLogConsoleSettings = "Aspire_ConsoleLog_ConsoleSettings"; + public const string ConsoleLogFilters = "Aspire_ConsoleLog_Filters"; public static string SplitterOrientationKey(string viewKey) { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index 8e3de576f0..67e243ce37 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -135,7 +135,62 @@ public async Task ResourceName_ViaUrlAndResourceLoaded_LogViewerUpdated() cut.WaitForState(() => instance._logEntries.EntriesCount > 0); } - private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = null) + [Fact] + public void ClearLogEntries_AllResources_LogsFilteredOut() + { + // Arrange + var consoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + var testResource = ModelTestHelpers.CreateResource(appName: "test-resource", state: KnownResourceState.Running); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: name => consoleLogsChannel, + resourceChannelProvider: () => resourceChannel, + initialResources: [ testResource ]); + var timeProvider = new TestTimeProvider(); + + SetupConsoleLogsServices(dashboardClient, timeProvider: timeProvider); + + var dimensionManager = Services.GetRequiredService(); + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + dimensionManager.InvokeOnViewportInformationChanged(viewport); + + // Act + var cut = RenderComponent(builder => + { + builder.Add(p => p.ResourceName, "test-resource"); + builder.Add(p => p.ViewportInformation, viewport); + }); + + var instance = cut.Instance; + var logger = Services.GetRequiredService>(); + var loc = Services.GetRequiredService>(); + + // Assert + logger.LogInformation("Waiting for selected resource."); + cut.WaitForState(() => instance.PageViewModel.SelectedResource == testResource); + cut.WaitForState(() => instance.PageViewModel.Status == loc[nameof(Resources.ConsoleLogs.ConsoleLogsWatchingLogs)]); + + logger.LogInformation("Log results are added to log viewer."); + consoleLogsChannel.Writer.TryWrite([new ResourceLogLine(1, "2025-02-08T10:16:08Z Hello world", IsErrorMessage: false)]); + cut.WaitForState(() => instance._logEntries.EntriesCount > 0); + + // Set current time to the date of the first entry so all entries are cleared. + var earliestEntry = instance._logEntries.GetEntries()[0]; + timeProvider.UtcNow = earliestEntry.Timestamp!.Value; + + logger.LogInformation("Clear current entries."); + cut.Find(".clear-button").Click(); + cut.Find("#clear-menu-all").Click(); + + cut.WaitForState(() => instance._logEntries.EntriesCount == 0); + + logger.LogInformation("New log results are added to log viewer."); + consoleLogsChannel.Writer.TryWrite([new ResourceLogLine(2, "2025-03-08T10:16:08Z Hello world", IsErrorMessage: false)]); + cut.WaitForState(() => instance._logEntries.EntriesCount > 0); + } + + private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = null, TestTimeProvider? timeProvider = null) { var version = typeof(FluentMain).Assembly.GetName().Version!; @@ -153,6 +208,9 @@ private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = nul var keycodeModule = JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/KeyCode/FluentKeyCode.razor.js", version)); keycodeModule.Setup("RegisterKeyCode", _ => true); + JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Anchor/FluentAnchor.razor.js", version)); + JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/AnchoredRegion/FluentAnchoredRegion.razor.js", version)); + JSInterop.SetupVoid("initializeContinuousScroll"); JSInterop.SetupVoid("resetContinuousScrollPosition"); @@ -160,7 +218,7 @@ private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = nul Services.AddLocalization(); Services.AddSingleton(loggerFactory); - Services.AddSingleton(); + Services.AddSingleton(timeProvider ?? new TestTimeProvider()); Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton>(Options.Create(new DashboardOptions())); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestTimeProvider.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestTimeProvider.cs index 8cfc284b3d..fc4d4c4df5 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestTimeProvider.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestTimeProvider.cs @@ -10,13 +10,16 @@ public sealed class TestTimeProvider : BrowserTimeProvider { private TimeZoneInfo? _localTimeZone; + public DateTimeOffset UtcNow { get; set; } + public TestTimeProvider() : base(NullLoggerFactory.Instance) { + UtcNow = new DateTimeOffset(2025, 12, 20, 23, 59, 59, TimeSpan.Zero); } public override DateTimeOffset GetUtcNow() { - return new DateTimeOffset(2025, 12, 20, 23, 59, 59, TimeSpan.Zero); + return UtcNow; } public override TimeZoneInfo LocalTimeZone => _localTimeZone ??= TimeZoneInfo.CreateCustomTimeZone(nameof(TestTimeProvider), TimeSpan.FromHours(1), nameof(TestTimeProvider), nameof(TestTimeProvider));