File: Components\Pages\ResourceOverview.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 System.Globalization;
using Aspire.Dashboard.Components.Controls;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Aspire.Shared.ConsoleLogs;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
 
namespace Aspire.Dashboard.Components.Pages;
 
/// <summary>
/// Summarizes the selected resource: its status, endpoints and relationships, a preview of recent console output and
/// telemetry, and the complete resource details. Each summary links to the tab that shows the full data.
/// When several resources are selected, it displays a summary card for each of them instead.
/// </summary>
public sealed partial class ResourceOverview : ComponentBase, IAsyncDisposable
{
    private const int ConsolePreviewLineCount = 10;
    private const int RecentItemCount = 5;
    private const int MetricsPreviewCount = 8;
    // Matches the metrics page's default duration, so a tile's sparkline looks like the card it opens.
    private static readonly TimeSpan s_metricsPreviewDuration = TimeSpan.FromMinutes(5);
 
    // Telemetry can arrive many times per second. Coalesce updates so the overview queries the repository at most
    // a couple of times per second.
    private static readonly TimeSpan s_telemetryRefreshDelay = TimeSpan.FromMilliseconds(500);
    private static readonly TimeSpan s_consoleRenderDelay = TimeSpan.FromMilliseconds(250);
    private static readonly TimeSpan s_consoleLoadingTimeout = TimeSpan.FromSeconds(1);
 
    private readonly object _consoleLock = new();
    private readonly List<ConsolePreviewLine> _consoleLines = new();
    private CancellationTokenSource? _resourceCts;
    private string? _subscribedResourceName;
    private ResourceKey? _telemetryKey;
    private Subscription? _logsSubscription;
    private Subscription? _tracesSubscription;
    private Subscription? _metricsSubscription;
    private int _telemetryRefreshPending;
    private int _consoleRenderPending;
    private bool _hasConsoleLogs;
    private bool _consoleLogsLoaded;
    private bool _telemetryLoaded;
    private OverviewTelemetry? _telemetry;
    private ResourcesLayout? _subscribedLayout;
    private IDisposable? _consoleLogsFiltersChangedSubscription;
    private TerminalView? _terminalView;
    private bool? _renderedTerminalFocus;
 
    [Parameter]
    public string? ResourceName { get; set; }
 
    [CascadingParameter]
    public ResourcesLayout? ResourcesLayout { get; set; }
 
    [Inject]
    public required DashboardDataSource DataSource { get; init; }
 
    [Inject]
    public required IDashboardClient DashboardClient { get; init; }
 
    [Inject]
    public required ConsoleLogsManager ConsoleLogsManager { get; init; }
 
    [Inject]
    public required PauseManager PauseManager { get; init; }
 
    [Inject]
    public required DashboardCommandExecutor DashboardCommandExecutor { get; init; }
 
    [Inject]
    public required BrowserTimeProvider TimeProvider { get; init; }
 
    [Inject]
    public required IconResolver IconResolver { get; init; }
 
    [Inject]
    public required IVolumePathLauncher VolumePathLauncher { get; init; }
 
    [Inject]
    public required TerminalDockState TerminalDockState { get; init; }
 
    [Inject]
    public required Microsoft.FluentUI.AspNetCore.Components.INotificationService ToastService { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.Resources> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.Columns> ColumnsLoc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.ControlsStrings> ControlsStringsLoc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.Layout> LayoutLoc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.ConsoleLogs> ConsoleLogsLoc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.TerminalStrings> TerminalLoc { get; init; }
 
    [Inject]
    public required ILogger<ResourceOverview> Logger { get; init; }
 
    [Inject]
    public required NavigationManager NavigationManager { get; init; }
 
    [Inject]
    public required MetricPinStore PinStore { get; init; }
 
    private ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
 
    private ResourceViewModel? Resource => ResourcesLayout?.SelectedResource;
 
    private bool IsTerminalFocused => ResourcesLayout?.CurrentTab == ResourceTab.Terminal &&
        Resource is { } resource && ResourceSelectHelpers.HasUsableTerminal(resource);
 
    private void ToggleTerminalFocus() =>
        NavigationManager.NavigateTo(DashboardUrls.SetResourceTerminalFocus(NavigationManager.Uri, !IsTerminalFocused));
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (_renderedTerminalFocus != IsTerminalFocused)
        {
            var wasRendered = _renderedTerminalFocus is not null;
            _renderedTerminalFocus = IsTerminalFocused;
            if (wasRendered && _terminalView is not null)
            {
                // Focus changes the existing card's size, not its connection. Explicit activation
                // reapplies its sizing policy once; ordinary resizes must not fight other viewers.
                await _terminalView.RefreshLayoutAsync();
            }
        }
    }
 
    protected override async Task OnInitializedAsync()
    {
        // The Metrics card lists pinned instruments, which can change on the metrics page in another tab.
        PinStore.Changed += OnPinsChanged;
        TerminalDockState.Changed += OnTerminalDockStateChanged;
 
        // The console preview hides lines removed with the console logs page's "Remove" action, which are stored
        // as filters in session storage.
        await ConsoleLogsManager.EnsureInitializedAsync();
        _consoleLogsFiltersChangedSubscription = ConsoleLogsManager.OnFiltersChanged(() => InvokeAsync(() =>
        {
            if (_subscribedResourceName is { } resourceName)
            {
                StartConsoleSubscription(resourceName);
            }
 
            StateHasChanged();
        }));
    }
 
    protected override void OnParametersSet()
    {
        if (!ReferenceEquals(_subscribedLayout, ResourcesLayout))
        {
            _subscribedLayout?.ResourcesChanged -= OnResourcesChanged;
            _subscribedLayout = ResourcesLayout;
            _subscribedLayout?.ResourcesChanged += OnResourcesChanged;
        }
 
        EnsureSubscriptions();
        UpdateDetails();
    }
 
    private void OnPinsChanged()
    {
        _ = InvokeAsync(StateHasChanged);
    }
 
    private void OnResourcesChanged()
    {
        EnsureSubscriptions();
        UpdateDetails();
 
        // The telemetry resource can appear after the app model resource, for example when a project starts
        // sending telemetry. Refresh so the telemetry cards pick it up.
        QueueTelemetryRefresh();
        StateHasChanged();
    }
 
    /// <summary>
    /// Starts console log and telemetry subscriptions for the selected resource, replacing subscriptions for a
    /// previously selected resource. The page instance is reused when navigating between resources.
    /// </summary>
    private void EnsureSubscriptions()
    {
        var resource = Resource;
        var otlpResource = resource is not null ? TelemetryRepository.GetResourceByCompositeName(resource.Name) : null;
 
        if (string.Equals(_subscribedResourceName, resource?.Name, StringComparisons.ResourceName) &&
            Equals(_telemetryKey, otlpResource?.ResourceKey))
        {
            return;
        }
 
        var resourceChanged = !string.Equals(_subscribedResourceName, resource?.Name, StringComparisons.ResourceName);
        _subscribedResourceName = resource?.Name;
 
        if (resourceChanged)
        {
            StartConsoleSubscription(resource?.Name);
        }
 
        _logsSubscription?.Dispose();
        _tracesSubscription?.Dispose();
        _metricsSubscription?.Dispose();
        _logsSubscription = _tracesSubscription = _metricsSubscription = null;
        _telemetry = null;
        // Cleared whenever the telemetry key changes so the telemetry cards show their loading skeleton again
        // instead of flashing the previous resource's stale data or popping in abruptly once the new data arrives.
        _telemetryLoaded = false;
        _telemetryKey = otlpResource?.ResourceKey;
 
        if (_telemetryKey is { } key)
        {
            _logsSubscription = TelemetryRepository.OnNewLogs(key, SubscriptionType.Other, OnTelemetryChangedAsync);
            _tracesSubscription = TelemetryRepository.OnNewTraces(key, SubscriptionType.Other, OnTelemetryChangedAsync);
            _metricsSubscription = TelemetryRepository.OnNewMetrics(key, SubscriptionType.Other, OnTelemetryChangedAsync);
            QueueTelemetryRefresh();
        }
    }
 
    private Task OnTelemetryChangedAsync()
    {
        QueueTelemetryRefresh();
        return Task.CompletedTask;
    }
 
    private void QueueTelemetryRefresh()
    {
        if (_telemetryKey is null || Interlocked.Exchange(ref _telemetryRefreshPending, 1) == 1)
        {
            return;
        }
 
        var cancellationToken = _resourceCts?.Token ?? CancellationToken.None;
        _ = Task.Run(async () =>
        {
            try
            {
                await Task.Delay(s_telemetryRefreshDelay, cancellationToken).ConfigureAwait(false);
                Interlocked.Exchange(ref _telemetryRefreshPending, 0);
 
                if (_telemetryKey is not { } key)
                {
                    return;
                }
 
                var telemetry = await LoadTelemetryAsync(key, cancellationToken).ConfigureAwait(false);
                await InvokeAsync(() =>
                {
                    // Ignore results for a resource that is no longer selected.
                    if (Equals(_telemetryKey, key))
                    {
                        _telemetry = telemetry;
                        _telemetryLoaded = true;
                        StateHasChanged();
                    }
                }).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                Interlocked.Exchange(ref _telemetryRefreshPending, 0);
            }
            catch (Exception ex)
            {
                Interlocked.Exchange(ref _telemetryRefreshPending, 0);
                Logger.LogWarning(ex, "Error loading telemetry summary for the resource overview.");
            }
        }, CancellationToken.None);
    }
 
    private async Task<OverviewTelemetry> LoadTelemetryAsync(ResourceKey key, CancellationToken cancellationToken)
    {
        var repository = TelemetryRepository;
 
        var allLogs = await repository.GetLogSummariesAsync(CreateLogsContext(key, [], 0, 0), cancellationToken).ConfigureAwait(false);
        List<TelemetryFilter> errorFilters = [new FieldTelemetryFilter { Field = nameof(OtlpLogEntry.Severity), Condition = FilterCondition.GreaterThanOrEqual, Value = nameof(LogLevel.Error) }];
        List<TelemetryFilter> warningFilters = [new FieldTelemetryFilter { Field = nameof(OtlpLogEntry.Severity), Condition = FilterCondition.Equals, Value = nameof(LogLevel.Warning) }];
        List<TelemetryFilter> attentionFilters = [new FieldTelemetryFilter { Field = nameof(OtlpLogEntry.Severity), Condition = FilterCondition.GreaterThanOrEqual, Value = nameof(LogLevel.Warning) }];
 
        var errorLogs = await repository.GetLogSummariesAsync(CreateLogsContext(key, errorFilters, 0, 0), cancellationToken).ConfigureAwait(false);
        var warningLogs = await repository.GetLogSummariesAsync(CreateLogsContext(key, warningFilters, 0, 0), cancellationToken).ConfigureAwait(false);
 
        // Results are ordered oldest first, so read the last page to get the most recent entries.
        var attentionCount = errorLogs.TotalItemCount + warningLogs.TotalItemCount;
        var recentAttentionLogs = attentionCount > 0
            ? (await repository.GetLogSummariesAsync(CreateLogsContext(key, attentionFilters, Math.Max(0, attentionCount - RecentItemCount), RecentItemCount), cancellationToken).ConfigureAwait(false)).Items
            : [];
 
        // A healthy resource has no warnings or errors to highlight, so show its latest logs instead of an empty card.
        var recentLogs = attentionCount == 0 && allLogs.TotalItemCount > 0
            ? (await repository.GetLogSummariesAsync(CreateLogsContext(key, [], Math.Max(0, allLogs.TotalItemCount - RecentItemCount), RecentItemCount), cancellationToken).ConfigureAwait(false)).Items
            : [];
 
        var allTraces = await repository.GetTraceSummariesAsync(CreateTracesRequest(key, [], 0, 0), cancellationToken).ConfigureAwait(false);
        List<TelemetryFilter> failedTraceFilters = [new FieldTelemetryFilter { Field = KnownTraceFields.StatusField, Condition = FilterCondition.Equals, Value = nameof(OtlpSpanStatusCode.Error) }];
        var failedTraces = await repository.GetTraceSummariesAsync(CreateTracesRequest(key, failedTraceFilters, 0, 0), cancellationToken).ConfigureAwait(false);
        var traceCount = allTraces.PagedResult.TotalItemCount;
        var recentTraces = traceCount > 0
            ? (await repository.GetTraceSummariesAsync(CreateTracesRequest(key, [], Math.Max(0, traceCount - RecentItemCount), RecentItemCount), cancellationToken).ConfigureAwait(false)).PagedResult.Items
            : [];
 
        var instruments = repository.GetInstrumentSummaries(key)
            .OrderBy(i => i.Parent.Name, StringComparer.Ordinal)
            .ThenBy(i => i.Name, StringComparer.Ordinal)
            .ToList();
 
        return new OverviewTelemetry
        {
            LogCount = allLogs.TotalItemCount,
            ErrorLogCount = errorLogs.TotalItemCount,
            WarningLogCount = warningLogs.TotalItemCount,
            RecentAttentionLogs = recentAttentionLogs.AsEnumerable().Reverse().ToList(),
            RecentLogs = recentLogs.AsEnumerable().Reverse().ToList(),
            TraceCount = traceCount,
            FailedTraceCount = failedTraces.PagedResult.TotalItemCount,
            RecentTraces = recentTraces.AsEnumerable().Reverse().ToList(),
            Instruments = instruments
        };
 
        static GetLogsContext CreateLogsContext(ResourceKey key, List<TelemetryFilter> filters, int startIndex, int count) => new()
        {
            ResourceKeys = [key],
            StartIndex = startIndex,
            Count = count,
            Filters = filters
        };
 
        static GetTracesRequest CreateTracesRequest(ResourceKey key, List<TelemetryFilter> filters, int startIndex, int count) => new()
        {
            ResourceKeys = [key],
            StartIndex = startIndex,
            Count = count,
            Filters = filters
        };
    }
 
    /// <summary>
    /// Replaces the console log subscription and clears the preview. Called when the selected resource changes, and
    /// when console log filters change so removed lines disappear from the preview.
    /// </summary>
    private void StartConsoleSubscription(string? resourceName)
    {
        _resourceCts?.Cancel();
        _resourceCts?.Dispose();
        _resourceCts = null;
 
        lock (_consoleLock)
        {
            _consoleLines.Clear();
            _hasConsoleLogs = false;
            _consoleLogsLoaded = false;
        }
 
        if (resourceName is not null)
        {
            _resourceCts = new CancellationTokenSource();
            _ = SubscribeConsoleLogsAsync(resourceName, _resourceCts.Token);
        }
    }
 
    private async Task SubscribeConsoleLogsAsync(string resourceName, CancellationToken cancellationToken)
    {
        // The subscription doesn't yield anything for a resource without console logs. Stop displaying the
        // loading indicator after a short delay so the card shows that there are no logs.
        _ = MarkConsoleLogsLoadedAfterDelayAsync(cancellationToken);
 
        try
        {
            // Subscribe through the dashboard client, like the console logs page. With a live app host, it streams
            // the resource's logs from the app host and stores them. The resource repository only has the lines
            // that a dashboard client subscription already stored.
            await foreach (var batch in DashboardClient.SubscribeConsoleLogs(resourceName, cancellationToken).ConfigureAwait(false))
            {
                var filterDate = ConsoleLogsManager.GetFilterDate(resourceName);
                lock (_consoleLock)
                {
                    foreach (var line in batch)
                    {
                        var previewLine = CreatePreviewLine(line);
                        if (IsHidden(previewLine, filterDate))
                        {
                            continue;
                        }
 
                        InsertConsoleLine(previewLine);
                    }
 
                    _hasConsoleLogs |= batch.Count > 0;
                    _consoleLogsLoaded = true;
                }
 
                QueueConsoleRender(cancellationToken);
            }
        }
        catch (OperationCanceledException)
        {
            return;
        }
        catch (Exception ex)
        {
            Logger.LogWarning(ex, "Error reading console logs for the resource overview.");
        }
 
        lock (_consoleLock)
        {
            _consoleLogsLoaded = true;
        }
        await InvokeAsync(StateHasChanged).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Gets whether a line is hidden because it was removed from the console logs page, or because it was written
    /// while console logs were paused. Matches the lines the console logs page displays.
    /// </summary>
    private bool IsHidden(ConsolePreviewLine line, DateTime? filterDate)
    {
        if (line.Timestamp is not { } timestamp)
        {
            // Without a timestamp, a line can't be matched to a pause interval. It's a new line if it's received
            // while paused.
            return PauseManager.ConsoleLogsPaused;
        }
 
        var utcTimestamp = timestamp.UtcDateTime;
        if (filterDate is not null && utcTimestamp <= filterDate)
        {
            return true;
        }
 
        foreach (var pause in PauseManager.ConsoleLogPauseIntervals)
        {
            if (utcTimestamp >= pause.Start && (pause.End is null || utcTimestamp <= pause.End))
            {
                return true;
            }
        }
 
        return false;
    }
 
    /// <summary>
    /// Pauses or resumes console logs. The pause is shared with the console logs page, which displays the pause and
    /// the number of lines that were skipped.
    /// </summary>
    private void OnConsolePausedChanged(bool isPaused)
    {
        PauseManager.SetConsoleLogsPaused(isPaused, TimeProvider.GetUtcNow().UtcDateTime);
    }
 
    private string? ConsolePauseText => PauseManager.ConsoleLogPauseIntervals.LastOrDefault() is { End: null } pause
        ? string.Format(
            CultureInfo.CurrentCulture,
            ConsoleLogsLoc[nameof(Dashboard.Resources.ConsoleLogs.PauseInProgressText)],
            FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, pause.Start))
        : null;
 
    private async Task MarkConsoleLogsLoadedAfterDelayAsync(CancellationToken cancellationToken)
    {
        try
        {
            await Task.Delay(s_consoleLoadingTimeout, cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            return;
        }
 
        lock (_consoleLock)
        {
            if (_consoleLogsLoaded)
            {
                return;
            }
 
            _consoleLogsLoaded = true;
        }
 
        await InvokeAsync(StateHasChanged).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Resources can write a large backlog when the subscription starts. Renders are throttled while it's replayed so
    /// the preview doesn't re-render for every batch. The render runs after the delay so the last batch is displayed.
    /// </summary>
    private void QueueConsoleRender(CancellationToken cancellationToken)
    {
        if (Interlocked.Exchange(ref _consoleRenderPending, 1) == 1)
        {
            return;
        }
 
        _ = Task.Run(async () =>
        {
            try
            {
                await Task.Delay(s_consoleRenderDelay, cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                return;
            }
            finally
            {
                Interlocked.Exchange(ref _consoleRenderPending, 0);
            }
 
            await InvokeAsync(StateHasChanged).ConfigureAwait(false);
        }, CancellationToken.None);
    }
 
    /// <summary>
    /// Converts a raw console line into plain text for the preview. Lines usually start with an RFC 3339 timestamp
    /// and can contain ANSI color sequences, for example:
    /// <c>2025-01-01T10:00:00.1234567Z \u001b[32minfo\u001b[0m: Now listening on: http://localhost:5000</c>.
    /// </summary>
    private static ConsolePreviewLine CreatePreviewLine(ResourceLogLine line)
    {
        var content = line.Content;
        DateTimeOffset? timestamp = null;
        if (TimestampParser.TryParseConsoleTimestamp(content, out var result))
        {
            content = result.Value.ModifiedText;
            timestamp = result.Value.Timestamp;
        }
 
        return new ConsolePreviewLine(line.LineNumber, AnsiParser.StripControlSequences(content), timestamp, line.IsErrorMessage);
    }
 
    /// <summary>
    /// Inserts a line in timestamp order and keeps the most recent lines. Lines from different sources, such as the
    /// app host's <c>[sys]</c> lines and the process output, can arrive out of order. The console logs page sorts them
    /// by timestamp too.
    /// </summary>
    private void InsertConsoleLine(ConsolePreviewLine line)
    {
        var index = _consoleLines.Count;
        if (line.Timestamp is { } timestamp)
        {
            while (index > 0 && _consoleLines[index - 1].Timestamp is { } previous && previous > timestamp)
            {
                index--;
            }
        }
 
        _consoleLines.Insert(index, line);
        if (_consoleLines.Count > ConsolePreviewLineCount)
        {
            _consoleLines.RemoveAt(0);
        }
    }
 
    private List<ConsolePreviewLine> GetConsoleLines()
    {
        lock (_consoleLock)
        {
            return _consoleLines.ToList();
        }
    }
 
    private sealed record EndpointGroup(DisplayedUrl Primary, DisplayedUrl? Secondary);
 
    /// <summary>
    /// Resources commonly expose the same logical endpoint twice: once reachable from outside the container
    /// (the public address) and once reachable only from the host/other containers (the internal address). Both
    /// urls share the same display name, so showing them as two separate rows just duplicates the row's label and
    /// scheme badge. Collapse them into a single row, preferring the public url as the clickable link and keeping
    /// the internal url as supplementary text.
    /// </summary>
    private static List<EndpointGroup> GroupEndpoints(IReadOnlyList<DisplayedUrl> urls)
    {
        var groups = new List<EndpointGroup>();
        var consumedIndexes = new HashSet<int>();
 
        for (var i = 0; i < urls.Count; i++)
        {
            if (!consumedIndexes.Add(i))
            {
                continue;
            }
 
            var primary = urls[i];
            DisplayedUrl? secondary = null;
 
            for (var j = i + 1; j < urls.Count; j++)
            {
                if (consumedIndexes.Contains(j))
                {
                    continue;
                }
 
                var candidate = urls[j];
                if (string.Equals(candidate.Name, primary.Name, StringComparison.Ordinal) &&
                    string.Equals(candidate.DisplayName ?? candidate.Text, primary.DisplayName ?? primary.Text, StringComparison.Ordinal))
                {
                    secondary = candidate;
                    consumedIndexes.Add(j);
                    break;
                }
            }
 
            if (secondary is not null && primary.IsInternal && !secondary.IsInternal)
            {
                (primary, secondary) = (secondary, primary);
            }
 
            groups.Add(new EndpointGroup(primary, secondary));
        }
 
        return groups;
    }
 
    private string GetResourceName(ResourceViewModel resource) => ResourcesLayout?.GetResourceName(resource) ?? resource.Name;
 
    private IEnumerable<RelatedResource> GetRelatedResources(ResourceViewModel resource)
    {
        if (ResourcesLayout is null)
        {
            return [];
        }
 
        var resources = ResourcesLayout.ResourceByName.Values.Where(r => !r.IsResourceHidden(showHiddenResources: false)).ToList();
        var related = new Dictionary<string, RelatedResource>(StringComparers.ResourceName);
 
        // Relationships reference other resources by display name, so every replica of the target is related.
        foreach (var relationship in resource.Relationships)
        {
            foreach (var match in resources.Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName) && r != resource))
            {
                related.TryAdd(match.Name, new RelatedResource(match, relationship.Type, IsIncoming: false));
            }
        }
 
        foreach (var other in resources.Where(r => r != resource))
        {
            if (other.Relationships.FirstOrDefault(r => string.Equals(r.ResourceName, resource.DisplayName, StringComparisons.ResourceName)) is { } relationship)
            {
                related.TryAdd(other.Name, new RelatedResource(other, relationship.Type, IsIncoming: true));
            }
        }
 
        return related.Values
            .OrderBy(r => r.IsIncoming)
            .ThenBy(r => GetResourceName(r.Resource), StringComparers.ResourceName);
    }
 
    private List<RelationshipGraphNode> GetGraphNodes(IEnumerable<RelatedResource> relatedResources, bool incoming) =>
        relatedResources
            .Where(r => r.IsIncoming == incoming)
            .Select(r =>
            {
                var name = GetResourceName(r.Resource);
                return new RelationshipGraphNode(name, r.Type, DashboardUrls.ResourceOverviewUrl(name));
            })
            .ToList();
 
    private Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command)
        => DashboardCommandExecutor.ExecuteAsync(resource, command, GetResourceName);
 
    private Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command, ExecuteResourceCommandOptions options)
        => DashboardCommandExecutor.ExecuteAsync(resource, command, GetResourceName, options);
 
    public ValueTask DisposeAsync()
    {
        _subscribedLayout?.ResourcesChanged -= OnResourcesChanged;
        PinStore.Changed -= OnPinsChanged;
        TerminalDockState.Changed -= OnTerminalDockStateChanged;
 
        _resourceCts?.Cancel();
        _resourceCts?.Dispose();
        _logsSubscription?.Dispose();
        _tracesSubscription?.Dispose();
        _metricsSubscription?.Dispose();
        _consoleLogsFiltersChangedSubscription?.Dispose();
        return ValueTask.CompletedTask;
    }
 
    private void OnTerminalDockStateChanged()
        => _ = InvokeAsync(StateHasChanged);
 
    private sealed record ConsolePreviewLine(int LineNumber, string Content, DateTimeOffset? Timestamp, bool IsError);
 
    private sealed record SelectionGroup(string Title, List<string> Names);
 
    // Common app model types come first in a fixed order; any other types follow alphabetically.
    private static readonly string[] s_selectionGroupOrder = [KnownResourceTypes.Project, KnownResourceTypes.Container, KnownResourceTypes.Executable];
 
    private List<SelectionGroup> GetSelectionGroups(IEnumerable<string> names)
    {
        var telemetryOnlyTitle = LayoutLoc[nameof(Dashboard.Resources.Layout.ResourcePaneTelemetryOnly)].Value;
        return names
            .GroupBy(name => ResourcesLayout is not null && ResourceViewModel.TryGetResourceByName(name, ResourcesLayout.ResourceByName, out var resource)
                ? resource.ResourceType
                : telemetryOnlyTitle, StringComparer.Ordinal)
            .OrderBy(g => g.Key == telemetryOnlyTitle ? 1 : 0)
            .ThenBy(g => Array.IndexOf(s_selectionGroupOrder, g.Key) is var index and >= 0 ? index : s_selectionGroupOrder.Length)
            .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
            .Select(g => new SelectionGroup(g.Key, g.ToList()))
            .ToList();
    }
 
    private sealed record RelatedResource(ResourceViewModel Resource, string Type, bool IsIncoming);
 
    private sealed class OverviewTelemetry
    {
        public required int LogCount { get; init; }
        public required int ErrorLogCount { get; init; }
        public required int WarningLogCount { get; init; }
        public required List<LogSummary> RecentAttentionLogs { get; init; }
        public required List<LogSummary> RecentLogs { get; init; }
        public required int TraceCount { get; init; }
        public required int FailedTraceCount { get; init; }
        public required List<TraceSummary> RecentTraces { get; init; }
        public required List<OtlpInstrumentSummary> Instruments { get; init; }
    }
 
    internal enum OverviewCard
    {
        Status,
        Endpoints,
        Relationships,
        Console,
        Logs,
        Traces,
        Metrics,
        Properties,
        Environment,
        Volumes,
        Health
    }
 
    /// <summary>
    /// Packs cards into rows of a 12 column grid in page order. Each card requests a span based on its content, and the
    /// spare columns at the end of a row are shared between that row's cards so rows never have trailing gaps.
    /// Medium widths use a separate two-per-row packing where wide cards take the full row.
    /// </summary>
    internal sealed class OverviewCardLayout
    {
        private const int Columns = 12;
 
        private readonly Dictionary<OverviewCard, int> _spans;
        private readonly Dictionary<OverviewCard, int> _mediumSpans;
 
        private OverviewCardLayout(Dictionary<OverviewCard, int> spans, Dictionary<OverviewCard, int> mediumSpans)
        {
            _spans = spans;
            _mediumSpans = mediumSpans;
        }
 
        public static OverviewCardLayout Create(IReadOnlyList<(OverviewCard Card, int Span)> cards)
        {
            return new OverviewCardLayout(
                Pack(cards),
                Pack(cards.Select(c => (c.Card, c.Span >= 7 ? Columns : Columns / 2)).ToList()));
        }
 
        public string Style(OverviewCard card)
        {
            return string.Create(CultureInfo.InvariantCulture, $"--overview-span: {_spans.GetValueOrDefault(card, Columns)}; --overview-span-md: {_mediumSpans.GetValueOrDefault(card, Columns)}; --overview-card-height: {GetHeight(card)}px;");
        }
 
        // Each card has a fixed height, so a card is the same size for every resource and the cards below it don't
        // move. Content that doesn't fit scrolls inside the card. Cards on the same row share a height.
        private static int GetHeight(OverviewCard card) => card switch
        {
            OverviewCard.Status or OverviewCard.Endpoints or OverviewCard.Metrics => 260,
            OverviewCard.Console or OverviewCard.Logs => 300,
            OverviewCard.Traces or OverviewCard.Relationships => 300,
            OverviewCard.Properties or OverviewCard.Environment => 340,
            _ => 240
        };
 
        private static Dictionary<OverviewCard, int> Pack(IReadOnlyList<(OverviewCard Card, int Span)> cards)
        {
            var result = new Dictionary<OverviewCard, int>();
            var row = new List<(OverviewCard Card, int Span)>();
            var used = 0;
 
            foreach (var (card, requested) in cards)
            {
                var span = Math.Clamp(requested, 1, Columns);
                if (used + span > Columns)
                {
                    CompleteRow();
                }
                row.Add((card, span));
                used += span;
            }
            CompleteRow();
 
            return result;
 
            void CompleteRow()
            {
                var spare = Columns - used;
                for (var i = 0; i < row.Count; i++)
                {
                    result[row[i].Card] = row[i].Span + (spare / row.Count) + (i < spare % row.Count ? 1 : 0);
                }
                row.Clear();
                used = 0;
            }
        }
    }
}