File: Components\Layout\MainLayout.razor.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Components.Resize;
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 IDisposable? _themeChangedSubscription;
    private IDisposable? _locationChangingRegistration;
    private IJSObjectReference? _jsModule;
    private IJSObjectReference? _keyboardHandlers;
    private IJSObjectReference? _textVisualizerHandler;
    private DotNetObjectReference<ShortcutManager>? _shortcutManagerReference;
    private DotNetObjectReference<MainLayout>? _layoutReference;
    private IDialogReference? _openPageDialog;
 
    private const string SettingsDialogId = "SettingsDialog";
    private const string HelpDialogId = "HelpDialog";
    private const string MessageBarSection = "MessagesTop";
 
    [Inject]
    public required ThemeManager ThemeManager { get; init; }
 
    [Inject]
    public required BrowserTimeProvider TimeProvider { get; init; }
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.Layout> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.Dialogs> DialogsLoc { get; init; }
 
    [Inject]
    public required IDialogService 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; }
 
    [CascadingParameter]
    public required ViewportInformation ViewportInformation { get; set; }
 
    protected override async Task OnInitializedAsync()
    {
        // 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.Theme!;
 
                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<string>("window.getBrowserTimeZone");
        TimeProvider.SetBrowserTimeZone(result);
 
        if (Options.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured)
        {
            // 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.MessageTelemetryTitle)];
                options.Body = Loc[nameof(Resources.Layout.MessageTelemetryBody)];
                options.Link = new()
                {
                    Text = Loc[nameof(Resources.Layout.MessageTelemetryLink)],
                    Href = "https://aka.ms/dotnet/aspire/telemetry-unsecured",
                    Target = "_blank"
                };
                options.Intent = MessageIntent.Warning;
                options.Section = MessageBarSection;
                options.AllowDismiss = true;
            });
        }
    }
 
    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);
            _textVisualizerHandler = await JS.InvokeAsync<IJSObjectReference>("window.registerOpenTextVisualizerOnClick", _layoutReference);
            ShortcutManager.AddGlobalKeydownListener(this);
        }
    }
 
    protected override void OnParametersSet()
    {
        if (ViewportInformation.IsDesktop && _isNavMenuOpen)
        {
            _isNavMenuOpen = false;
            CloseMobileNavMenu();
        }
    }
 
    private async Task LaunchHelpAsync()
    {
        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)
        };
 
        if (_openPageDialog is not null)
        {
            if (Equals(_openPageDialog.Id, HelpDialogId))
            {
                return;
            }
 
            await _openPageDialog.CloseAsync();
        }
 
        _openPageDialog = await DialogService.ShowDialogAsync<HelpDialog>(parameters).ConfigureAwait(true);
    }
 
    private void HandleDialogClose(DialogInstance dialogResult)
    {
        _openPageDialog = null;
    }
 
    public async Task LaunchSettingsAsync()
    {
        DialogParameters parameters = new()
        {
            Title = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogTitle)],
            PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)],
            PrimaryActionEnabled = true,
            SecondaryAction = null,
            TrapFocus = true,
            Modal = true,
            Alignment = HorizontalAlignment.Right,
            Width = "300px",
            Height = "auto",
            Id = SettingsDialogId,
            OnDialogClosing = EventCallback.Factory.Create<DialogInstance>(this, HandleDialogClose)
        };
 
        if (_openPageDialog is not null)
        {
            if (Equals(_openPageDialog.Id, SettingsDialogId))
            {
                return;
            }
 
            await _openPageDialog.CloseAsync();
        }
 
        _openPageDialog = await DialogService.ShowPanelAsync<SettingsDialog>(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();
    }
 
    [JSInvokable]
    public async Task OpenTextVisualizerAsync(IJSStreamReference valueStream, string valueDescription)
    {
        var width = ViewportInformation.IsDesktop ? "75vw" : "100vw";
        var parameters = new DialogParameters
        {
            Title = valueDescription,
            Width = $"min(1000px, {width})",
            TrapFocus = true,
            Modal = true,
            PreventScroll = true,
        };
 
        await using var referenceStream = await valueStream.OpenReadStreamAsync();
        using var reader = new StreamReader(referenceStream);
        var value = await reader.ReadToEndAsync();
 
        await DialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(value, valueDescription), parameters);
    }
 
    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);
            }
 
            if (_textVisualizerHandler is not null)
            {
                await JS.InvokeVoidAsync("window.unregisterOpenTextVisualizerOnClick", _textVisualizerHandler);
            }
        }
        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);
        await JSInteropHelpers.SafeDisposeAsync(_textVisualizerHandler);
    }
}