// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Components.Pages;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
namespace Aspire.Dashboard.Components.Layout;
public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable
{
private bool _isNavMenuOpen;
private bool _runSelectionChanged;
private bool _isSwitchingRuns;
// Desktop nav rail layout. false = collapsed to icons only (default, most content space,
// labels still available via each item's tooltip); true = expanded so each item shows its
// icon on the left and text label on the right. Persisted per-browser in local storage.
private bool _isNavMenuExpanded;
private IDisposable? _themeChangedSubscription;
private IDisposable? _locationChangingRegistration;
private IJSObjectReference? _jsModule;
private IJSObjectReference? _keyboardHandlers;
private DotNetObjectReference<ShortcutManager>? _shortcutManagerReference;
private DotNetObjectReference<MainLayout>? _layoutReference;
private IDialogReference? _openPageDialog;
private string? _pendingReturnFocusElementId;
private bool _suppressNextDialogFocusRestore;
private const string SettingsDialogId = "SettingsDialog";
private const string HelpDialogId = "HelpDialog";
private const string NotificationsDialogId = "NotificationsDialog";
private const string AIAgentsDialogId = "AIAgentsDialog";
internal const string HelpButtonId = "dashboard-help-button";
internal const string SettingsButtonId = "dashboard-settings-button";
internal const string NavigationButtonId = "dashboard-navigation-button";
[Inject]
public required ThemeManager ThemeManager { get; init; }
[Inject]
public required BrowserTimeProvider TimeProvider { get; init; }
[Inject]
public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }
[Inject]
public required IJSRuntime JS { get; init; }
[Inject]
public required IStringLocalizer<Resources.Layout> Loc { get; init; }
[Inject]
public required DashboardDialogService DialogService { get; init; }
[Inject]
public required NavigationManager NavigationManager { get; init; }
[Inject]
public required IDashboardClient DashboardClient { get; init; }
[Inject]
public required ShortcutManager ShortcutManager { get; init; }
[Inject]
public required IMessageService MessageService { get; init; }
[Inject]
public required IOptionsMonitor<DashboardOptions> Options { get; init; }
[Inject]
public required ILocalStorage LocalStorage { get; init; }
[Inject]
public required ISessionStorage SessionStorage { get; init; }
[Inject]
public required IDashboardRunStore RunStore { get; init; }
[Inject]
public required IDashboardRunSelection RunSelection { get; init; }
[Inject]
public required ILogger<MainLayout> Logger { get; init; }
[CascadingParameter]
public required ViewportInformation ViewportInformation { get; set; }
protected override async Task OnInitializedAsync()
{
if (RunStore.SupportsRunSelection)
{
var selectedRunResult = await SessionStorage.GetAsync<string>(BrowserStorageKeys.SelectedDashboardRunId);
var selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null;
if (!_runSelectionChanged && !string.IsNullOrEmpty(selectedRunId))
{
try
{
RunSelection.SelectRun(selectedRunId);
}
catch (Exception exception)
{
Logger.LogError(exception, "Failed to restore dashboard run '{RunId}'. Falling back to the current run.", selectedRunId);
RunSelection.SelectRun(runId: null);
}
if (RunSelection.SelectedRun.IsCurrent)
{
await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, string.Empty);
}
}
}
// Theme change can be triggered from the settings dialog. This logic applies the new theme to the browser window.
// Note that this event could be raised from a settings dialog opened in a different browser window.
_themeChangedSubscription = ThemeManager.OnThemeChanged(async () =>
{
if (_jsModule is not null)
{
var newValue = ThemeManager.SelectedTheme!;
var effectiveTheme = await _jsModule.InvokeAsync<string>("updateTheme", newValue);
ThemeManager.EffectiveTheme = effectiveTheme;
}
});
// Redirect to the structured logs page if the dashboard has no resource service.
if (!DashboardClient.IsEnabled)
{
_locationChangingRegistration = NavigationManager.RegisterLocationChangingHandler((context) =>
{
if (TargetLocationInterceptor.InterceptTargetLocation(NavigationManager.BaseUri, context.TargetLocation, out var newTargetLocation))
{
context.PreventNavigation();
NavigationManager.NavigateTo(newTargetLocation);
}
return ValueTask.CompletedTask;
});
}
var result = await JS.InvokeAsync<BrowserInfo>("window.getBrowserInfo");
TimeProvider.SetBrowserTimeZone(result.TimeZone);
TimeProvider.SetBrowserTimeFormat(result.Is24HourTime ? TimeFormat.TwentyFourHour : TimeFormat.TwelveHour);
TelemetryContextProvider.SetBrowserUserAgent(result.UserAgent);
var timeFormatResult = await LocalStorage.GetAsync<TimeFormat>(BrowserStorageKeys.TimeFormat);
if (timeFormatResult.Success)
{
TimeProvider.SetConfiguredTimeFormat(timeFormatResult.Value);
}
// Restore the persisted desktop nav rail layout (collapsed to icons vs. expanded with labels).
var navExpandedResult = await LocalStorage.GetUnprotectedAsync<bool>(BrowserStorageKeys.NavMenuExpanded);
if (navExpandedResult.Success)
{
_isNavMenuExpanded = navExpandedResult.Value;
}
await DisplayUnsecuredEndpointsMessageAsync();
}
private async Task DisplayUnsecuredEndpointsMessageAsync()
{
var unsecuredEndpointsMessage = new StringBuilder();
if (ShouldShowUnsecuredTelemetryMessage())
{
unsecuredEndpointsMessage.AppendLine(Loc[nameof(Resources.Layout.MessageUnsecuredEndpointTelemetryBody)]);
}
if (ShouldShowUnsecuredApiMessage())
{
unsecuredEndpointsMessage.AppendLine(Loc[nameof(Resources.Layout.MessageUnsecuredEndpointApiBody)]);
}
if (unsecuredEndpointsMessage.Length > 0)
{
// Check UnsecuredTelemetryMessageDismissedKey for backwards compatibility.
var skipMessage = (await ShouldSkipMessageAsync(LocalStorage, BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey) ||
await ShouldSkipMessageAsync(LocalStorage, BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey));
if (!skipMessage)
{
// ShowMessageBarAsync must come after an await. Otherwise it will NRE.
// I think this order allows the message bar provider to be fully initialized.
await MessageService.ShowMessageBarAsync(options =>
{
options.Title = Loc[nameof(Resources.Layout.MessageUnsecuredEndpointTitle)];
options.Body = unsecuredEndpointsMessage.ToString();
options.Link = new()
{
Text = Loc[nameof(Resources.Layout.MessageUnsecuredEndpointLink)],
Href = "https://aka.ms/aspire/api-endpoint-unsecured",
Target = "_blank"
};
options.Intent = MessageIntent.Warning;
options.Section = DashboardUIHelpers.MessageBarSection;
options.AllowDismiss = true;
options.OnClose = async m =>
{
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey, true);
};
});
}
}
static async Task<bool> ShouldSkipMessageAsync(ILocalStorage localStorage, string storageKey)
{
var dismissedResult = await localStorage.GetUnprotectedAsync<bool>(storageKey);
return dismissedResult.Success && dismissedResult.Value;
}
}
private bool ShouldShowUnsecuredTelemetryMessage()
{
// Only show warning if at least one OTLP endpoint is configured
return (Options.CurrentValue.Otlp.GetGrpcEndpointAddress() != null || Options.CurrentValue.Otlp.GetHttpEndpointAddress() != null) &&
Options.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured &&
!Options.CurrentValue.Otlp.SuppressUnsecuredMessage;
}
private bool ShouldShowUnsecuredApiMessage()
{
// Only show warning if API is enabled and unsecured
return !Options.CurrentValue.Api.Disabled.GetValueOrDefault() &&
Options.CurrentValue.Api.AuthMode == ApiAuthMode.Unsecured;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "/js/app-theme.js");
_shortcutManagerReference = DotNetObjectReference.Create(ShortcutManager);
_layoutReference = DotNetObjectReference.Create(this);
_keyboardHandlers = await JS.InvokeAsync<IJSObjectReference>("window.registerGlobalKeydownListener", _shortcutManagerReference);
ShortcutManager.AddGlobalKeydownListener(this);
}
if (_pendingReturnFocusElementId is { } elementId && _openPageDialog is null)
{
_pendingReturnFocusElementId = null;
await JS.InvokeVoidAsync("focusElement", elementId);
}
}
protected override void OnParametersSet()
{
if (ViewportInformation.IsDesktop && _isNavMenuOpen)
{
_isNavMenuOpen = false;
CloseMobileNavMenu();
}
}
private string GetDefaultReturnFocusElementId(string desktopButtonId) => ViewportInformation.IsDesktop ? desktopButtonId : NavigationButtonId;
private async Task SwitchDashboardRunAsync(string? runId)
{
_runSelectionChanged = true;
var selectedRunId = RunSelection.SelectedRun is { IsCurrent: false } selectedRun ? selectedRun.RunId : null;
if (string.Equals(runId, selectedRunId, StringComparison.Ordinal))
{
await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, runId ?? string.Empty);
return;
}
_isSwitchingRuns = true;
await InvokeAsync(StateHasChanged);
try
{
RunSelection.SelectRun(runId);
}
catch (Exception exception)
{
Logger.LogError(
exception,
"Failed to switch to dashboard run '{RunId}'. Keeping dashboard run '{SelectedRunId}' selected.",
runId,
RunSelection.SelectedRun.RunId);
}
finally
{
_isSwitchingRuns = false;
await InvokeAsync(StateHasChanged);
}
var persistedRunId = RunSelection.SelectedRun is { IsCurrent: false } actualSelectedRun ? actualSelectedRun.RunId : string.Empty;
await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, persistedRunId);
}
private string? GetVisibleReturnFocusElementId(string? returnFocusElementId, string desktopButtonId)
{
// Dialog launchers move between the desktop header and the mobile navigation menu.
// Resolve the target when the dialog closes so viewport changes do not focus a removed element.
return returnFocusElementId is null ? null : GetDefaultReturnFocusElementId(desktopButtonId);
}
private Task LaunchHelpAsync() => LaunchHelpAsync(GetDefaultReturnFocusElementId(HelpButtonId));
private async Task LaunchHelpAsync(string? returnFocusElementId)
{
DialogParameters parameters = new()
{
Title = Loc[nameof(Resources.Layout.MainLayoutAspireDashboardHelpLink)],
PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)],
PrimaryActionEnabled = true,
SecondaryAction = null,
TrapFocus = true,
Modal = true,
Alignment = HorizontalAlignment.Center,
Width = "700px",
Height = "auto",
Id = HelpDialogId,
OnDialogClosing = EventCallback.Factory.Create<DialogInstance>(this, _ => HandleDialogClose(GetVisibleReturnFocusElementId(returnFocusElementId, HelpButtonId)))
};
if (!await CloseOpenPageDialogForReplacementAsync(HelpDialogId).ConfigureAwait(true))
{
return;
}
_openPageDialog = await DialogService.ShowDialogAsync<HelpDialog>(parameters).ConfigureAwait(true);
}
private void HandleDialogClose(string? returnFocusElementId = null)
{
_openPageDialog = null;
if (!_suppressNextDialogFocusRestore)
{
_pendingReturnFocusElementId = returnFocusElementId;
}
}
private async Task<bool> CloseOpenPageDialogForReplacementAsync(string dialogId)
{
if (_openPageDialog is null)
{
return true;
}
if (Equals(_openPageDialog.Id, dialogId) && !_openPageDialog.Result.IsCompleted)
{
return false;
}
_suppressNextDialogFocusRestore = true;
try
{
await _openPageDialog.CloseAsync();
_pendingReturnFocusElementId = null;
}
finally
{
_suppressNextDialogFocusRestore = false;
}
return true;
}
public async Task LaunchAIAgentsAsync()
{
DialogParameters parameters = new()
{
Title = Loc[nameof(Resources.Layout.MainLayoutLaunchAIAgents)],
PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)],
PrimaryActionEnabled = true,
SecondaryAction = null,
TrapFocus = true,
Modal = true,
Alignment = HorizontalAlignment.Center,
Width = "700px",
Height = "auto",
Id = AIAgentsDialogId,
OnDialogClosing = EventCallback.Factory.Create<DialogInstance>(this, _ => HandleDialogClose())
};
if (!await CloseOpenPageDialogForReplacementAsync(AIAgentsDialogId).ConfigureAwait(true))
{
return;
}
_openPageDialog = await DialogService.ShowDialogAsync<AIAgentsDialog>(parameters).ConfigureAwait(true);
}
public Task LaunchSettingsAsync() => LaunchSettingsAsync(GetDefaultReturnFocusElementId(SettingsButtonId));
private async Task LaunchSettingsAsync(string? returnFocusElementId)
{
var parameters = new DialogParameters
{
Title = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogTitle)],
PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)].Value,
SecondaryAction = null,
TrapFocus = true,
Modal = true,
Alignment = HorizontalAlignment.Right,
Width = "300px",
Height = "auto",
Id = SettingsDialogId,
OnDialogClosing = EventCallback.Factory.Create<DialogInstance>(this, _ => HandleDialogClose(GetVisibleReturnFocusElementId(returnFocusElementId, SettingsButtonId)))
};
if (!await CloseOpenPageDialogForReplacementAsync(SettingsDialogId).ConfigureAwait(true))
{
return;
}
// Ensure the currently set theme is immediately available to display in settings dialog.
await ThemeManager.EnsureInitializedAsync();
if (ViewportInformation.IsDesktop)
{
_openPageDialog = await DialogService.ShowPanelAsync<SettingsDialog>(parameters).ConfigureAwait(true);
}
else
{
_openPageDialog = await DialogService.ShowDialogAsync<SettingsDialog>(parameters).ConfigureAwait(true);
}
}
public async Task LaunchNotificationsAsync()
{
var parameters = new DialogParameters
{
Title = Loc[nameof(Resources.Layout.MainLayoutNotificationCenterTitle)],
PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)].Value,
SecondaryAction = null,
TrapFocus = true,
Modal = true,
Alignment = HorizontalAlignment.Right,
Width = "350px",
Height = "auto",
Id = NotificationsDialogId,
OnDialogClosing = EventCallback.Factory.Create<DialogInstance>(this, _ => HandleDialogClose())
};
if (!await CloseOpenPageDialogForReplacementAsync(NotificationsDialogId).ConfigureAwait(true))
{
return;
}
if (ViewportInformation.IsDesktop)
{
_openPageDialog = await DialogService.ShowPanelAsync<NotificationsDialog>(parameters).ConfigureAwait(true);
}
else
{
_openPageDialog = await DialogService.ShowDialogAsync<NotificationsDialog>(parameters).ConfigureAwait(true);
}
}
public IReadOnlySet<AspireKeyboardShortcut> SubscribedShortcuts { get; } = new HashSet<AspireKeyboardShortcut>
{
AspireKeyboardShortcut.Help,
AspireKeyboardShortcut.Settings,
AspireKeyboardShortcut.GoToResources,
AspireKeyboardShortcut.GoToConsoleLogs,
AspireKeyboardShortcut.GoToStructuredLogs,
AspireKeyboardShortcut.GoToTraces,
AspireKeyboardShortcut.GoToMetrics
};
public async Task OnPageKeyDownAsync(AspireKeyboardShortcut shortcut)
{
switch (shortcut)
{
case AspireKeyboardShortcut.Help:
await LaunchHelpAsync();
break;
case AspireKeyboardShortcut.Settings:
await LaunchSettingsAsync();
break;
case AspireKeyboardShortcut.GoToResources:
NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl());
break;
case AspireKeyboardShortcut.GoToConsoleLogs:
NavigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl());
break;
case AspireKeyboardShortcut.GoToStructuredLogs:
NavigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl());
break;
case AspireKeyboardShortcut.GoToTraces:
NavigationManager.NavigateTo(DashboardUrls.TracesUrl());
break;
case AspireKeyboardShortcut.GoToMetrics:
NavigationManager.NavigateTo(DashboardUrls.MetricsUrl());
break;
}
}
private void CloseMobileNavMenu()
{
_isNavMenuOpen = false;
StateHasChanged();
}
private async Task ToggleNavMenuExpandedAsync()
{
_isNavMenuExpanded = !_isNavMenuExpanded;
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.NavMenuExpanded, _isNavMenuExpanded);
}
public async ValueTask DisposeAsync()
{
_shortcutManagerReference?.Dispose();
_layoutReference?.Dispose();
_themeChangedSubscription?.Dispose();
_locationChangingRegistration?.Dispose();
ShortcutManager.RemoveGlobalKeydownListener(this);
try
{
if (_keyboardHandlers is { } h)
{
await JS.InvokeVoidAsync("window.unregisterGlobalKeydownListener", h);
}
}
catch (JSDisconnectedException)
{
// Per https://learn.microsoft.com/aspnet/core/blazor/javascript-interoperability/?view=aspnetcore-7.0#javascript-interop-calls-without-a-circuit
// this is one of the calls that will fail if the circuit is disconnected, and we just need to catch the exception so it doesn't pollute the logs
}
await JSInteropHelpers.SafeDisposeAsync(_jsModule);
await JSInteropHelpers.SafeDisposeAsync(_keyboardHandlers);
}
}