File: Model\DashboardExperienceRedirectMiddleware.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.Configuration;
using Microsoft.Extensions.Options;
 
namespace Aspire.Dashboard.Model;
 
internal sealed class DashboardExperienceRedirectMiddleware(
    RequestDelegate next,
    IOptionsMonitor<DashboardOptions> options,
    DashboardExperienceAvailability availability)
{
    private const string ExperienceCookieName = "Aspire_Dashboard_Experience";
 
    public Task InvokeAsync(HttpContext context)
    {
        if (!HttpMethods.IsGet(context.Request.Method) ||
            !context.Request.Headers.Accept.Any(value => value?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true))
        {
            return next(context);
        }
 
        var path = context.Request.Path.Value ?? "/";
        if (IsExcludedPath(path))
        {
            return next(context);
        }
 
        var isLegacyPath = string.Equals(path, "/legacy", StringComparison.OrdinalIgnoreCase) ||
            path.StartsWith("/legacy/", StringComparison.OrdinalIgnoreCase);
        var cookieValue = context.Request.Cookies[ExperienceCookieName];
        var experience = availability.LegacyAvailable && options.CurrentValue.UI.DisableModernDashboard == true
            ? "legacy"
            : availability.LegacyAvailable && cookieValue is "legacy"
                ? "legacy"
                : cookieValue == "modern"
                ? cookieValue
                : "modern";
 
        // An explicit empty fragment prevents browsers from carrying the source URL's fragment across
        // the HTTP redirect. The browser normalizes the final URL to the URL without '#'. The redirect uses the
        // escaped path so resource names with reserved characters survive it.
        var escapedPath = context.Request.Path.ToUriComponent();
        if (experience == "legacy" && !isLegacyPath)
        {
            context.Response.Redirect($"{context.Request.PathBase.ToUriComponent()}{GetLegacyUrl(escapedPath, context.Request.QueryString.Value)}#");
            return Task.CompletedTask;
        }
 
        if (experience == "modern" && isLegacyPath)
        {
            context.Response.Redirect($"{context.Request.PathBase.ToUriComponent()}{GetModernUrl(escapedPath, context.Request.QueryString.Value)}#");
            return Task.CompletedTask;
        }
 
        return next(context);
    }
 
    // Pages that both experiences have, with the same route and query string below the experience's root, for example
    // /traces/detail/{traceId} and /legacy/traces/detail/{traceId}. Links to them, such as links from the CLI or a
    // shared trace link, keep working in either experience. Other pages, such as the resources pages, differ between
    // the experiences, so they go to the experience's root.
    // Keep in sync with wwwroot/js/app-dashboard-experience.js.
    private static readonly string[] s_sharedPageSegments = ["consolelogs", "structuredlogs", "traces", "metrics", "terminals"];
 
    internal static string GetLegacyUrl(string path, string? query)
    {
        if (IsSharedPage(path))
        {
            return $"/legacy{path}{query}";
        }
 
        // The root is the resources page in the previous experience, and its query selects a resource or the view and
        // filters, for example /?resource=api from the CLI's describe command and MCP tools, or a /?view=Graph bookmark.
        return path is "/" or "" ? $"/legacy{query}" : "/legacy";
    }
 
    internal static string GetModernUrl(string path, string? query)
    {
        // "/legacy/traces/detail/abc" -> "/traces/detail/abc", "/legacy" -> "".
        var modernPath = path["/legacy".Length..];
        return IsSharedPage(modernPath) ? $"{modernPath}{query}" : "/";
    }
 
    private static bool IsSharedPage(string path)
    {
        // The first segment of "/traces/detail/abc" is "traces".
        var segment = path.TrimStart('/');
        var separatorIndex = segment.IndexOf('/');
        if (separatorIndex >= 0)
        {
            segment = segment[..separatorIndex];
        }
 
        return s_sharedPageSegments.Contains(segment, StringComparer.OrdinalIgnoreCase);
    }
 
    private static bool IsExcludedPath(string path) =>
        string.Equals(path, "/login", StringComparison.OrdinalIgnoreCase) ||
        path.StartsWith("/error", StringComparison.OrdinalIgnoreCase) ||
        path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) ||
        path.StartsWith("/_", StringComparison.OrdinalIgnoreCase) ||
        path.StartsWith("/terminal-window", StringComparison.OrdinalIgnoreCase);
}
 
internal sealed record DashboardExperienceAvailability(bool LegacyAvailable);