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

Clear button on ConsoleLogs page #7419

Merged
merged 5 commits into from
Feb 8, 2025
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 @@ -39,7 +39,7 @@
{ "title", item.Tooltip ?? string.Empty }
};

<FluentMenuItem OnClick="() => HandleItemClicked(item)" Disabled="@item.IsDisabled" AdditionalAttributes="@additionalMenuItemAttributes">
<FluentMenuItem Id="@item.Id" OnClick="() => HandleItemClicked(item)" Disabled="@item.IsDisabled" AdditionalAttributes="@additionalMenuItemAttributes">
@item.Text
@if (item.Icon != null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ protected override void OnParametersSet()

_clearMenuItems.Add(new()
{
Id = "clear-menu-all",
Icon = s_clearAllResourcesIcon,
OnClick = () => HandleClearSignal(null),
Text = ControlsStringsLoc[name: nameof(ControlsStrings.ClearAllResources)],
});

_clearMenuItems.Add(new()
{
Id = "clear-menu-resource",
Icon = s_clearSelectedResourceIcon,
OnClick = () => HandleClearSignal(SelectedResource.Id?.GetApplicationKey()),
IsDisabled = SelectedResource.Id == null,
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
@bind-SelectedResource:after="HandleSelectedOptionChangedAsync"
LabelClass="toolbar-left" />

<ClearSignalsButton SelectedResource="PageViewModel.SelectedOption" HandleClearSignal="ClearConsoleLogs" />

@foreach (var command in _highlightedCommands)
{
<FluentButton Appearance="Appearance.Lightweight"
Expand Down
65 changes: 63 additions & 2 deletions src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
using Aspire.Hosting.ConsoleLogs;
Expand Down Expand Up @@ -70,6 +71,9 @@ private sealed class ConsoleLogsSubscription
[Inject]
public required DashboardCommandExecutor DashboardCommandExecutor { get; init; }

[Inject]
public required BrowserTimeProvider TimeProvider { get; init; }

[CascadingParameter]
public required ViewportInformation ViewportInformation { get; init; }

Expand All @@ -95,6 +99,8 @@ private sealed class ConsoleLogsSubscription
private bool _showTimestamp;
public ConsoleLogsViewModel PageViewModel { get; set; } = null!;

private ConsoleLogFilters _consoleLogFilters = new();

public string BasePath => DashboardUrls.ConsoleLogBasePath;
public string SessionStorageKey => BrowserStorageKeys.ConsoleLogsPageState;

Expand All @@ -111,6 +117,12 @@ protected override async Task OnInitializedAsync()
_showTimestamp = showTimestamp;
}

var filtersResult = await SessionStorage.GetAsync<ConsoleLogFilters>(BrowserStorageKeys.ConsoleLogFilters);
if (filtersResult.Value is { } filters)
{
_consoleLogFilters = filters;
}

var loadingTcs = new TaskCompletionSource();

await TrackResourceSnapshotsAsync();
Expand Down Expand Up @@ -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))
{
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -551,6 +606,12 @@ public record ConsoleLogsPageState(string? SelectedResource);

public record ConsoleLogConsoleSettings(bool ShowTimestamp);

public class ConsoleLogFilters
{
public DateTime? FilterAllLogsDate { get; set; }
public Dictionary<string, DateTime> FilterResourceLogsDates { get; set; } = [];
}

public Task UpdateViewModelFromQueryAsync(ConsoleLogsViewModel viewModel)
{
if (_resources is not null && ResourceName is not null)
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Model/MenuButtonItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ public class MenuButtonItem
public Icon? Icon { get; set; }
public Func<Task>? OnClick { get; set; }
public bool IsDisabled { get; set; }
public string Id { get; set; } = Identifier.NewId();
public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
}
10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Otlp/Storage/ApplicationKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,14 @@ public bool EqualsCompositeName(string name)

return true;
}

public override string ToString()
{
if (InstanceId == null)
{
return Name;
}

return $"{Name}-{InstanceId}";
}
}
3 changes: 2 additions & 1 deletion src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
62 changes: 60 additions & 2 deletions tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IReadOnlyList<ResourceLogLine>>();
var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
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<DimensionManager>();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
dimensionManager.InvokeOnViewportInformationChanged(viewport);

// Act
var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
{
builder.Add(p => p.ResourceName, "test-resource");
builder.Add(p => p.ViewportInformation, viewport);
});

var instance = cut.Instance;
var logger = Services.GetRequiredService<ILogger<ConsoleLogsTests>>();
var loc = Services.GetRequiredService<IStringLocalizer<Resources.ConsoleLogs>>();

// 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!;

Expand All @@ -153,14 +208,17 @@ private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = nul
var keycodeModule = JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/KeyCode/FluentKeyCode.razor.js", version));
keycodeModule.Setup<string>("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");

var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper);

Services.AddLocalization();
Services.AddSingleton<ILoggerFactory>(loggerFactory);
Services.AddSingleton<BrowserTimeProvider, TestTimeProvider>();
Services.AddSingleton<BrowserTimeProvider>(timeProvider ?? new TestTimeProvider());
Services.AddSingleton<IMessageService, MessageService>();
Services.AddSingleton<IToastService, ToastService>();
Services.AddSingleton<IOptions<DashboardOptions>>(Options.Create(new DashboardOptions()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down