// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Concurrent;
using System.Globalization;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Model.ResourceGraph;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
namespace Aspire.Dashboard.Components.Controls;
/// <summary>
/// Summarizes the app: overall health, telemetry totals, a tile per resource, and recent problems. The resource
/// overview shows it when all resources are selected.
/// </summary>
public sealed partial class ApplicationSummary : ComponentBase, IAsyncDisposable
{
private const string GraphContainerId = "overviewResourcesGraphContainer";
private const string ResourceContextMenuHeaderId = "application-summary-resource-context-menu-header";
private const int RecentItemCount = 5;
private const int InsightItemCount = 4;
internal const int ActivityBucketCount = 30;
// Bounds the work done on each refresh; the activity window only needs the most recent spans.
private const int MaxActivitySpans = 5000;
private const int MaxActivityErrorLogs = 500;
internal static readonly TimeSpan s_activityBucketSize = TimeSpan.FromSeconds(30);
// Metrics arrive every few seconds from each resource, so coalesce telemetry notifications to bound the
// number of repository queries the page runs.
private static readonly TimeSpan s_telemetryRefreshDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan s_ageRefreshInterval = TimeSpan.FromSeconds(30);
private static readonly List<TelemetryFilter> s_errorLogFilters = [new FieldTelemetryFilter { Field = nameof(OtlpLogEntry.Severity), Condition = FilterCondition.GreaterThanOrEqual, Value = nameof(LogLevel.Error) }];
private static readonly List<TelemetryFilter> s_warningLogFilters = [new FieldTelemetryFilter { Field = nameof(OtlpLogEntry.Severity), Condition = FilterCondition.Equals, Value = nameof(LogLevel.Warning) }];
private static readonly FieldTelemetryFilter s_failedTraceFilter = new() { Field = KnownTraceFields.StatusField, Condition = FilterCondition.Equals, Value = nameof(OtlpSpanStatusCode.Error) };
private static readonly string s_failedTracesUrl = DashboardUrls.TracesUrl(filters: TelemetryFilterFormatter.SerializeFiltersToString([s_failedTraceFilter]));
private readonly ConcurrentDictionary<string, ResourceViewModel> _resourceByName = new(StringComparers.ResourceName);
private readonly CancellationTokenSource _cts = new();
private Task? _resourceSubscriptionTask;
private Task? _ageRefreshTask;
private Subscription? _logsSubscription;
private Subscription? _tracesSubscription;
private Subscription? _metricsSubscription;
private Dictionary<ResourceKey, int>? _unviewedErrorCounts;
private HomeTelemetry? _telemetry;
private int _telemetryRefreshPending;
private bool _isLoaded;
private IReadOnlyList<string>? _scopedResourceNames;
private DotNetObjectReference<ApplicationSummaryInterop>? _resourcesInteropReference;
private IJSObjectReference? _jsModule;
private bool _graphInitializing;
private bool _graphInitialized;
private bool _isDisposing;
private AspireMenu? _contextMenu;
private bool _contextMenuOpen;
private readonly List<MenuButtonItem> _contextMenuItems = [];
/// <summary>
/// Gets or sets the resources to summarize, by name. <see langword="null"/> summarizes every resource.
/// </summary>
[Parameter]
public IReadOnlyList<string>? ResourceNames { get; set; }
[Inject]
public required IDashboardClient DashboardClient { get; init; }
[Inject]
public required DashboardDataSource DataSource { get; init; }
[Inject]
public required IconResolver IconResolver { get; init; }
[Inject]
public required BrowserTimeProvider TimeProvider { 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 ILogger<ApplicationSummary> Logger { get; init; }
[Inject]
public required DashboardCommandExecutor DashboardCommandExecutor { get; init; }
[Inject]
public required ResourceMenuBuilder ResourceMenuBuilder { get; init; }
[Inject]
public required DashboardViewPreferences ViewPreferences { get; init; }
[Inject]
public required NavigationManager NavigationManager { get; init; }
[Inject]
public required IStringLocalizer<Dashboard.Resources.ControlsStrings> ControlsStringsLoc { get; init; }
[Inject]
public required IStringLocalizer<Dashboard.Resources.Dialogs> DialogsLoc { get; init; }
[Inject]
public required IStringLocalizer<Dashboard.Resources.Layout> LayoutLoc { get; init; }
[Inject]
public required IJSRuntime JS { get; init; }
[Inject]
public required DashboardDialogService DialogService { get; init; }
[CascadingParameter]
public required ViewportInformation ViewportInformation { get; init; }
private ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
private IReadOnlyList<IconToggleOption<GroupOverview>> OverviewOptions =>
new IconToggleOption<GroupOverview>[]
{
new(GroupOverview.Cards, new Icons.Regular.Size20.Grid(), DialogsLoc[nameof(Dashboard.Resources.Dialogs.SettingsDialogGroupOverviewCards)]),
new(GroupOverview.Summary, new Icons.Regular.Size20.Home(), DialogsLoc[nameof(Dashboard.Resources.Dialogs.SettingsDialogGroupOverviewSummary)]),
new(GroupOverview.Graph, new Icons.Regular.Size20.ShareAndroid(), Loc[nameof(Dashboard.Resources.Resources.GraphPageTitle)])
}
.Where(option => option.Value != GroupOverview.Graph || ViewPreferences.IsGraphEnabled)
.ToList();
private IconToggleOption<GroupOverview> GetCurrentOverviewOption() =>
OverviewOptions.First(option => option.Value == ViewPreferences.GroupOverview);
private int GetMissingParameterCount() =>
_resourceByName.Values.Count(resource => resource.HasMissingParameterValueState() && IsInScope(resource));
private string GetMissingParametersUrl() =>
DashboardUrls.AddResourceSelection(
DashboardUrls.ResourceListUrl(),
_resourceByName.Values
.Where(resource => resource.HasMissingParameterValueState() && IsInScope(resource))
.OrderBy(resource => resource.DisplayName, StringComparers.ResourceName)
.Select(resource => resource.Name));
private IList<MenuButtonItem> GetOverviewMenuItems() =>
OverviewOptions.Select(option => new MenuButtonItem
{
Text = option.Text,
StartIcon = option.Icon,
Role = MenuItemRole.Radio,
Checked = option.Value == ViewPreferences.GroupOverview,
OnClick = () => ViewPreferences.SetGroupOverviewAsync(option.Value),
RenderKey = option.Value
}).ToList();
private string CardDensityClass => ViewPreferences.Density switch
{
ResourceListDensity.Spacious => "resource-card-grid-spacious",
ResourceListDensity.Compact => "resource-card-grid-compact",
_ => "resource-card-grid-comfortable"
};
private string? GetResourceStartAge(ResourceViewModel resource) =>
resource.StartTimeStamp is { } startTimeStamp
? DashboardRunSelect.FormatRunAge(new DateTimeOffset(startTimeStamp), TimeProvider.GetUtcNow(), LayoutLoc)
: null;
// The card shows how long ago the resource started. The tooltip adds the exact time: "5 minutes ago (10:34:56 AM)".
private string? GetResourceStartTimeTooltip(ResourceViewModel resource)
{
if (resource.StartTimeStamp is not { } startTimeStamp)
{
return null;
}
// The resource server's time stamp has second precision, so no milliseconds.
return $"{GetResourceStartAge(resource)} ({FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, startTimeStamp)})";
}
protected override async Task OnInitializedAsync()
{
ViewPreferences.Changed += OnViewPreferencesChanged;
await ViewPreferences.EnsureLoadedAsync();
if (!DashboardClient.IsEnabled)
{
return;
}
_unviewedErrorCounts = TelemetryRepository.GetResourceUnviewedErrorLogsCount();
_logsSubscription = TelemetryRepository.OnNewLogs(null, SubscriptionType.Other, OnTelemetryChanged);
_tracesSubscription = TelemetryRepository.OnNewTraces(null, SubscriptionType.Other, OnTelemetryChanged);
_metricsSubscription = TelemetryRepository.OnNewMetrics(null, SubscriptionType.Other, OnTelemetryChanged);
QueueTelemetryRefresh();
var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_cts.Token);
foreach (var resource in snapshot)
{
_resourceByName[resource.Name] = resource;
}
_isLoaded = true;
_ageRefreshTask = RefreshAgesPeriodicallyAsync(_cts.Token);
_resourceSubscriptionTask = Task.Run(async () =>
{
await foreach (var changes in subscription.WithCancellation(_cts.Token).ConfigureAwait(false))
{
foreach (var (changeType, resource) in changes)
{
if (changeType == ResourceViewModelChangeType.Upsert)
{
_resourceByName[resource.Name] = resource;
}
else if (changeType == ResourceViewModelChangeType.Delete)
{
_resourceByName.TryRemove(resource.Name, out _);
}
}
await InvokeAsync(async () =>
{
StateHasChanged();
await UpdateResourceGraphResourcesAsync();
}).ConfigureAwait(false);
}
});
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_isDisposing || ViewPreferences.GroupOverview != GroupOverview.Graph || _graphInitialized || _graphInitializing)
{
return;
}
_graphInitializing = true;
try
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", $"/{Assets["js/app-resourcegraph.js"]}");
_resourcesInteropReference = DotNetObjectReference.Create(new ApplicationSummaryInterop(this));
var graphIcons = new GraphIconsDto(
new GraphMenuIconDto(
Path: ResourceGraphMapper.GetIconPathData(new Icons.Regular.Size16.Settings()),
LabelFormat: Loc[nameof(Dashboard.Resources.Resources.ResourcesGraphResourceActionsButton)].Value),
new GraphHiddenIconDto(
Path: ResourceGraphMapper.GetIconPathData(new Icons.Regular.Size16.EyeOff()),
Tooltip: ColumnsLoc[nameof(Dashboard.Resources.Columns.HiddenResourceIconTooltip)].Value));
await _jsModule.InvokeVoidAsync("initializeResourcesGraph", _resourcesInteropReference, graphIcons);
_graphInitialized = true;
await UpdateResourceGraphResourcesAsync();
}
catch (JSDisconnectedException ex)
{
Logger.LogDebug(ex, "Application overview graph rendering stopped because the circuit disconnected.");
}
catch (ObjectDisposedException ex) when (_isDisposing)
{
Logger.LogDebug(ex, "Application overview graph rendering stopped because the component was disposed.");
}
finally
{
_graphInitializing = false;
}
}
protected override void OnParametersSet()
{
// The overview passes the current selection. When it changes, the telemetry shown is for other resources.
if (_scopedResourceNames is null != ResourceNames is null ||
(_scopedResourceNames is not null && ResourceNames is not null && !_scopedResourceNames.SequenceEqual(ResourceNames, StringComparers.ResourceName)))
{
var isFirstSet = _scopedResourceNames is null && !_isLoaded;
_scopedResourceNames = ResourceNames?.ToList();
if (!isFirstSet && DashboardClient.IsEnabled)
{
_telemetry = null;
QueueTelemetryRefresh();
}
}
}
private Task OnTelemetryChanged()
{
QueueTelemetryRefresh();
return Task.CompletedTask;
}
private void QueueTelemetryRefresh()
{
// Single-flight: the gate (1) is held through delay, load and publish. Notifications that
// arrive while a refresh is running set the dirty flag (2) so exactly one trailing refresh runs.
if (Interlocked.CompareExchange(ref _telemetryRefreshPending, 1, 0) != 0)
{
Interlocked.CompareExchange(ref _telemetryRefreshPending, 2, 1);
return;
}
_ = Task.Run(async () =>
{
try
{
do
{
await Task.Delay(s_telemetryRefreshDelay, _cts.Token).ConfigureAwait(false);
Interlocked.Exchange(ref _telemetryRefreshPending, 1);
var telemetry = await LoadTelemetryAsync(_cts.Token).ConfigureAwait(false);
var unviewedErrorCounts = TelemetryRepository.GetResourceUnviewedErrorLogsCount();
await InvokeAsync(() =>
{
_telemetry = telemetry;
_unviewedErrorCounts = unviewedErrorCounts;
StateHasChanged();
}).ConfigureAwait(false);
}
while (Interlocked.CompareExchange(ref _telemetryRefreshPending, 0, 1) == 2);
}
catch (OperationCanceledException)
{
Interlocked.Exchange(ref _telemetryRefreshPending, 0);
}
catch (Exception ex)
{
Interlocked.Exchange(ref _telemetryRefreshPending, 0);
Logger.LogWarning(ex, "Error loading telemetry for the home page.");
}
}, CancellationToken.None);
}
private async Task<HomeTelemetry> LoadTelemetryAsync(CancellationToken cancellationToken)
{
var repository = TelemetryRepository;
// Telemetry queries take resource keys, where an empty list means every resource. A selection of resources
// that send no telemetry has nothing to summarize.
var resourceKeys = GetScopedResourceKeys();
if (resourceKeys is { Count: 0 })
{
return HomeTelemetry.Empty;
}
var queryKeys = resourceKeys ?? [];
var allLogs = await repository.GetLogSummariesAsync(CreateLogsContext([], 0, 0), cancellationToken).ConfigureAwait(false);
var warningLogs = await repository.GetLogSummariesAsync(CreateLogsContext(s_warningLogFilters, 0, 0), cancellationToken).ConfigureAwait(false);
var errorLogs = await repository.GetLogSummariesAsync(CreateLogsContext(s_errorLogFilters, 0, 0), cancellationToken).ConfigureAwait(false);
// Results are ordered oldest first, so read the last page to get the most recent entries.
var errorCount = errorLogs.TotalItemCount;
var recentErrors = errorCount > 0
? (await repository.GetLogSummariesAsync(CreateLogsContext(s_errorLogFilters, Math.Max(0, errorCount - RecentItemCount), RecentItemCount), cancellationToken).ConfigureAwait(false)).Items
: [];
var allTraces = await repository.GetTraceSummariesAsync(CreateTracesRequest([], 0, 0), cancellationToken).ConfigureAwait(false);
var failedTraces = await repository.GetTraceSummariesAsync(CreateTracesRequest([s_failedTraceFilter], 0, 0), cancellationToken).ConfigureAwait(false);
var failedTraceCount = failedTraces.PagedResult.TotalItemCount;
var recentFailedTraces = failedTraceCount > 0
? (await repository.GetTraceSummariesAsync(CreateTracesRequest([s_failedTraceFilter], Math.Max(0, failedTraceCount - RecentItemCount), RecentItemCount), cancellationToken).ConfigureAwait(false)).PagedResult.Items
: [];
var telemetryResources = repository.GetResources();
var instrumentCount = 0;
var meterNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var resource in telemetryResources.Where(r => resourceKeys is null || resourceKeys.Contains(r.ResourceKey)))
{
foreach (var instrument in repository.GetInstrumentSummaries(resource.ResourceKey))
{
instrumentCount++;
meterNames.Add(instrument.Parent.Name);
}
}
var activity = await LoadActivityAsync(repository, telemetryResources, queryKeys, errorCount, cancellationToken).ConfigureAwait(false);
return new HomeTelemetry
{
Activity = activity,
LogCount = allLogs.TotalItemCount,
WarningLogCount = warningLogs.TotalItemCount,
ErrorLogCount = errorCount,
RecentErrors = recentErrors.AsEnumerable().Reverse().ToList(),
TraceCount = allTraces.PagedResult.TotalItemCount,
FailedTraceCount = failedTraceCount,
RecentFailedTraces = recentFailedTraces.AsEnumerable().Reverse().ToList(),
InstrumentCount = instrumentCount,
MeterCount = meterNames.Count
};
GetLogsContext CreateLogsContext(List<TelemetryFilter> filters, int startIndex, int count) => new()
{
ResourceKeys = queryKeys,
StartIndex = startIndex,
Count = count,
Filters = filters
};
GetTracesRequest CreateTracesRequest(List<TelemetryFilter> filters, int startIndex, int count) => new()
{
ResourceKeys = queryKeys,
StartIndex = startIndex,
Count = count,
Filters = filters
};
}
/// <summary>
/// Aggregates recent incoming requests (server and consumer spans) into fixed time buckets, plus per-resource
/// and per-endpoint breakdowns used by the activity chart and insight cards.
/// </summary>
private async Task<HomeActivity> LoadActivityAsync(ITelemetryRepository repository, List<OtlpResource> telemetryResources, List<ResourceKey> resourceKeys, int errorLogCount, CancellationToken cancellationToken)
{
// Align the window to bucket boundaries so bars don't shift between refreshes.
var now = TimeProvider.GetUtcNow().UtcDateTime;
var windowEnd = new DateTime(((now.Ticks / s_activityBucketSize.Ticks) + 1) * s_activityBucketSize.Ticks, DateTimeKind.Utc);
var windowStart = windowEnd - (s_activityBucketSize * ActivityBucketCount);
// Only the window's request spans, with the few fields used here. This refreshes about once a second while
// telemetry arrives, so it must not materialize traces.
var requestSpans = await repository.GetRequestSpansAsync(new GetRequestSpansRequest
{
ResourceKeys = resourceKeys,
StartTime = windowStart,
EndTime = windowEnd,
MaxCount = MaxActivitySpans
}, cancellationToken).ConfigureAwait(false);
var spans = requestSpans.Items;
var resourceNames = new Dictionary<ResourceKey, string>();
string GetName(OtlpResource resource)
{
if (!resourceNames.TryGetValue(resource.ResourceKey, out var name))
{
name = OtlpHelpers.GetResourceName(resource, telemetryResources);
resourceNames[resource.ResourceKey] = name;
}
return name;
}
var okBuckets = new int[ActivityBucketCount];
var errorBuckets = new int[ActivityBucketCount];
var durations = new List<double>();
var byResource = new Dictionary<ResourceKey, ResourceActivity>();
var byEndpoint = new Dictionary<(ResourceKey, string), List<double>>();
foreach (var span in spans)
{
var index = (int)((span.StartTime - windowStart).Ticks / s_activityBucketSize.Ticks);
var isError = span.Status == OtlpSpanStatusCode.Error;
var durationMs = span.Duration.TotalMilliseconds;
(isError ? errorBuckets : okBuckets)[index]++;
durations.Add(durationMs);
var resource = span.Resource;
if (!byResource.TryGetValue(resource.ResourceKey, out var resourceActivity))
{
resourceActivity = new ResourceActivity(GetName(resource));
byResource[resource.ResourceKey] = resourceActivity;
}
resourceActivity.Requests++;
if (isError)
{
resourceActivity.ErrorSpans++;
}
var endpointKey = (resource.ResourceKey, GetEndpointLabel(span));
if (!byEndpoint.TryGetValue(endpointKey, out var endpointDurations))
{
endpointDurations = [];
byEndpoint[endpointKey] = endpointDurations;
}
endpointDurations.Add(durationMs);
}
// Error logs count toward a resource's hotspot score alongside failed requests.
var logCount = Math.Min(errorLogCount, MaxActivityErrorLogs);
if (logCount > 0)
{
var errorLogs = await repository.GetLogSummariesAsync(new GetLogsContext { ResourceKeys = resourceKeys, StartIndex = errorLogCount - logCount, Count = logCount, Filters = s_errorLogFilters }, cancellationToken).ConfigureAwait(false);
foreach (var log in errorLogs.Items)
{
if (log.TimeStamp < windowStart)
{
continue;
}
if (!byResource.TryGetValue(log.Resource.ResourceKey, out var resourceActivity))
{
resourceActivity = new ResourceActivity(GetName(log.Resource));
byResource[log.Resource.ResourceKey] = resourceActivity;
}
resourceActivity.ErrorLogs++;
}
}
var totalRequests = okBuckets.Sum() + errorBuckets.Sum();
var totalErrors = errorBuckets.Sum();
var windowMinutes = s_activityBucketSize.TotalMinutes * ActivityBucketCount;
if (requestSpans.TotalItemCount > spans.Count && spans.Count > 0)
{
// Only the newest MaxActivitySpans were sampled, so the rate must be computed over the
// interval those spans actually cover rather than the full window.
var oldestSampled = spans.Min(s => s.StartTime);
if (oldestSampled > windowStart)
{
windowMinutes = Math.Max(s_activityBucketSize.TotalMinutes, (windowEnd - oldestSampled).TotalMinutes);
}
}
return new HomeActivity
{
WindowStart = windowStart,
OkBuckets = okBuckets,
ErrorBuckets = errorBuckets,
TotalRequests = totalRequests,
RequestsPerMinute = totalRequests / windowMinutes,
ErrorRate = totalRequests == 0 ? null : (double)totalErrors / totalRequests,
P95 = durations.Count == 0 ? null : TimeSpan.FromMilliseconds(Percentile(durations, 0.95)),
ByResource = byResource,
SlowestEndpoints = byEndpoint
.Select(e => new EndpointInsight(byResource[e.Key.Item1].Name, e.Key.Item2, e.Value.Count, TimeSpan.FromMilliseconds(Percentile(e.Value, 0.95))))
.OrderByDescending(e => e.P95)
.Take(InsightItemCount)
.ToList(),
ErrorHotspots = byResource.Values
.Where(r => r.TotalErrors > 0)
.OrderByDescending(r => r.TotalErrors)
.Take(InsightItemCount)
.ToList(),
BusiestResources = byResource.Values
.Where(r => r.Requests > 0)
.OrderByDescending(r => r.Requests)
.Take(InsightItemCount)
.ToList()
};
}
// Prefer the low-cardinality route template ("GET /api/catalog/{id}") over the span name.
// See https://opentelemetry.io/docs/specs/semconv/http/http-spans/
private static string GetEndpointLabel(RequestSpanSummary span)
{
var route = span.HttpRoute;
if (string.IsNullOrEmpty(route))
{
return span.Name;
}
var method = span.HttpMethod;
return string.IsNullOrEmpty(method) ? route : $"{method} {route}";
}
private static double Percentile(List<double> values, double percentile)
{
values.Sort();
var index = (int)Math.Ceiling(percentile * values.Count) - 1;
return values[Math.Clamp(index, 0, values.Count - 1)];
}
/// <summary>
/// Gets the telemetry keys of the summarized resources, or <see langword="null"/> when every resource is summarized.
/// </summary>
private List<ResourceKey>? GetScopedResourceKeys()
{
if (_scopedResourceNames is not { } names)
{
return null;
}
var keys = new List<ResourceKey>();
foreach (var name in names)
{
// Selections name resources as the resource list shows them, which can be a display name or a replica's name.
var resourceName = ResourceViewModel.TryGetResourceByName(name, _resourceByName, out var resource) ? resource.Name : name;
if (TelemetryRepository.GetResourceByCompositeName(resourceName) is { } telemetryResource)
{
keys.Add(telemetryResource.ResourceKey);
}
}
return keys;
}
private bool IsInScope(ResourceViewModel resource) => _scopedResourceNames is not { } names ||
names.Contains(GetResourceName(resource), StringComparers.ResourceName) ||
names.Contains(resource.Name, StringComparers.ResourceName);
private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName);
// Like the resources grid, every row reserves room for as many highlighted commands as the busiest resource has, so
// the action columns line up. At most two are shown; the others are in the menu.
private static int GetMaxHighlightedCount(List<ResourceViewModel> resources) => Math.Min(
resources.Select(r => r.Commands.Count(c => c.IsHighlighted && c.State != CommandViewModelState.Hidden)).DefaultIfEmpty(0).Max(),
DashboardUIHelpers.MaxHighlightedCommands);
private bool HasMultipleReplicas(ResourceViewModel resource) =>
_resourceByName.Values.Count(r => !r.IsResourceHidden(showHiddenResources: false) && string.Equals(r.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) >= 2;
private void NavigateToResource(ResourceViewModel resource) => NavigationManager.NavigateTo(DashboardUrls.ResourceOverviewUrl(GetResourceName(resource)));
private void OnResourceCardKeyDown(KeyboardEventArgs args, ResourceViewModel resource)
{
if (args.Key is "Enter" or " ")
{
NavigateToResource(resource);
}
}
private Task ExecuteCommandAsync(ResourceViewModel resource, CommandViewModel command) =>
DashboardCommandExecutor.ExecuteAsync(resource, command, GetResourceName);
private async Task ShowContextMenuAsync(string id, int clientX, int clientY)
{
if (_contextMenu is not { } contextMenu || !_resourceByName.TryGetValue(id, out var resource))
{
return;
}
_contextMenuItems.Clear();
_contextMenuItems.Add(new MenuButtonItem
{
Id = ResourceContextMenuHeaderId,
IsHeader = true,
Text = GetResourceName(resource),
Icon = ResourceIconHelpers.GetIconForResource(IconResolver, resource, IconSize.Size16)
});
ResourceMenuBuilder.AddMenuItems(
_contextMenuItems,
resource,
_resourceByName,
EventCallback.Factory.Create(this, () =>
{
NavigateToResource(resource);
return Task.CompletedTask;
}),
EventCallback.Factory.Create<CommandViewModel>(this, command => ExecuteCommandAsync(resource, command)),
(menuResource, command) => DashboardCommandExecutor.IsExecuting(menuResource.Name, command.Name),
showViewDetails: true,
showTerminalItem: true,
showConsoleLogsItem: true,
showUrls: true);
await contextMenu.OpenAsync(clientX, clientY);
StateHasChanged();
}
private async Task ContextMenuOpenChangedAsync(bool open)
{
_contextMenuOpen = open;
if (_jsModule is not null)
{
await _jsModule.InvokeVoidAsync("updateResourcesGraphContextMenu", open);
}
await InvokeAsync(StateHasChanged);
}
private Task ExecuteCardCommandAsync(ResourceViewModel resource, CommandViewModel command)
{
if (string.Equals(command.Name, CommandViewModel.SetParameterCommand, StringComparisons.CommandName))
{
NavigateToResource(resource);
return Task.CompletedTask;
}
return ExecuteCommandAsync(resource, command);
}
private List<ResourceViewModel> GetResources()
{
return _resourceByName.Values
.Where(r => !r.IsParameter && !r.IsResourceHidden(showHiddenResources: false) && IsInScope(r))
.OrderBy(r => NeedsAttention(r) ? 0 : 1)
.ThenBy(r => r.ResourceType)
.ThenBy(r => r, ResourceViewModelNameComparer.Instance)
.ToList();
}
private List<ResourceViewModel> GetCardResources()
{
return _resourceByName.Values
.Where(r => !r.IsResourceHidden(showHiddenResources: false) && IsInScope(r))
.OrderBy(r => r.IsParameter ? 1 : 0)
.ThenBy(r => NeedsAttention(r) ? 0 : 1)
.ThenBy(r => r.ResourceType)
.ThenBy(r => r, ResourceViewModelNameComparer.Instance)
.ToList();
}
private static (string? Value, bool IsSensitive, bool IsUnresolved) GetParameterValue(ResourceViewModel resource)
{
var isUnresolved = !resource.IsRunningState();
if (resource.Properties.TryGetValue(KnownProperties.Parameter.Value, out var property))
{
var value = isUnresolved ? null : property.Value.HasStringValue ? property.Value.StringValue : null;
return (value, property.IsValueSensitive, isUnresolved);
}
return (null, false, isUnresolved);
}
private async Task UpdateResourceGraphResourcesAsync()
{
if (ViewPreferences.GroupOverview != GroupOverview.Graph || !_graphInitialized || _jsModule is null)
{
return;
}
try
{
await _jsModule.InvokeVoidAsync("updateResourcesGraph", GetResourceGraphResources());
}
catch (JSDisconnectedException ex)
{
Logger.LogDebug(ex, "Application overview graph update stopped because the circuit disconnected.");
}
catch (ObjectDisposedException ex) when (_isDisposing)
{
Logger.LogDebug(ex, "Application overview graph update stopped because the component was disposed.");
}
}
private List<ResourceDto> GetResourceGraphResources()
{
var resources = GetResources();
return resources
.Select(resource => ResourceGraphMapper.MapResource(resource, resources, _resourceByName, ColumnsLoc, showHiddenResources: false, IconResolver))
.ToList();
}
private Task ExportResourceGraphAsync()
{
return TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions
{
DialogService = DialogService,
ValueDescription = Loc[nameof(Dashboard.Resources.Resources.ResourcesGraphExportMermaidButton)],
MarkdownDescription = Loc[nameof(Dashboard.Resources.Resources.ResourcesGraphExportMermaidDescription)],
Value = ResourceGraphMermaidExporter.Export(GetResourceGraphResources()),
DownloadFileName = "resources.mmd",
FixedFormat = DashboardUIHelpers.PlaintextFormat
});
}
/// <summary>
/// Gets the health state bucket of a resource, as counted by the health bar.
/// </summary>
private HealthBucket GetHealthBucket(ResourceViewModel resource) => NeedsAttention(resource)
? HealthBucket.Attention
: ResourcesLayout.GetStateClass(resource) switch
{
"state-success" => HealthBucket.Running,
"state-pending" => HealthBucket.Pending,
_ => HealthBucket.Inactive
};
private bool NeedsAttention(ResourceViewModel resource)
{
return ResourcesLayout.GetStateClass(resource) is "state-error" or "state-warning" || GetUnviewedErrorCount(resource) > 0;
}
private int GetUnviewedErrorCount(ResourceViewModel resource)
{
return _unviewedErrorCounts is not null &&
TelemetryRepository.GetResourceByCompositeName(resource.Name) is { } otlpResource &&
_unviewedErrorCounts.TryGetValue(otlpResource.ResourceKey, out var count)
? count
: 0;
}
/// <summary>
/// Groups resources by the state buckets shown in the health bar. Every resource is counted in exactly one bucket.
/// </summary>
private HealthSummary GetHealthSummary(List<ResourceViewModel> resources)
{
int running = 0, pending = 0, attention = 0, inactive = 0;
DateTime? startedAt = null;
foreach (var resource in resources)
{
switch (GetHealthBucket(resource))
{
case HealthBucket.Attention:
attention++;
break;
case HealthBucket.Running:
running++;
break;
case HealthBucket.Pending:
pending++;
break;
default:
inactive++;
break;
}
if (resource.CreationTimeStamp is { } created && (startedAt is null || created < startedAt))
{
startedAt = created;
}
}
return new HealthSummary(resources.Count, running, pending, attention, inactive, startedAt);
}
private string GetHeadline(HealthSummary summary)
{
if (summary.Total == 0)
{
return Loc[nameof(Dashboard.Resources.Resources.ResourcesNoResources)];
}
if (summary.Attention > 0)
{
return Loc[nameof(Dashboard.Resources.Resources.HomeHeadlineAttention), summary.Attention, summary.Total];
}
if (summary.Pending > 0)
{
return Loc[nameof(Dashboard.Resources.Resources.HomeHeadlinePending), summary.Pending, summary.Total];
}
return summary.Running == summary.Total
? Loc[nameof(Dashboard.Resources.Resources.HomeHeadlineAllRunning), summary.Total]
: Loc[nameof(Dashboard.Resources.Resources.HomeHeadlineHealthy)];
}
private static string GetHeroStateClass(HealthSummary summary) => summary switch
{
{ Attention: > 0 } => "state-error",
{ Pending: > 0 } => "state-pending",
{ Total: > 0 } => "state-success",
_ => "state-neutral"
};
private static string FormatPercent(int value, int total) => total == 0
? "0%"
: string.Create(CultureInfo.InvariantCulture, $"{value * 100.0 / total:0.##}%");
private static string FormatCount(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
private string GetLogResourceName(OtlpResource resource) => OtlpHelpers.GetResourceName(resource, TelemetryRepository.GetResources());
// The settings panel changes what the overview shows while it's displayed.
private void OnViewPreferencesChanged() => _ = InvokeAsync(async () =>
{
StateHasChanged();
await UpdateResourceGraphResourcesAsync();
});
// The cards show how long ago a resource started, so re-render now and then to keep that current.
private async Task RefreshAgesPeriodicallyAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(s_ageRefreshInterval, TimeProvider);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
}
public async ValueTask DisposeAsync()
{
_isDisposing = true;
ViewPreferences.Changed -= OnViewPreferencesChanged;
_cts.Cancel();
_logsSubscription?.Dispose();
_tracesSubscription?.Dispose();
_metricsSubscription?.Dispose();
await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask);
await TaskHelpers.WaitIgnoreCancelAsync(_ageRefreshTask);
_resourcesInteropReference?.Dispose();
if (_jsModule is not null)
{
await _jsModule.DisposeAsync().AsTask().ConfigureAwait(false);
}
_cts.Dispose();
}
private sealed class ApplicationSummaryInterop(ApplicationSummary summary)
{
[JSInvokable]
public Task SelectResource(string id) => summary.SelectResourceAsync(id);
[JSInvokable]
public Task ResourceContextMenu(string id, int clientX, int clientY, string? _) =>
summary.ShowContextMenuAsync(id, clientX, clientY);
}
private async Task SelectResourceAsync(string id)
{
if (_resourceByName.TryGetValue(id, out var resource))
{
await InvokeAsync(() => NavigateToResource(resource));
}
}
private enum HealthBucket
{
Attention,
Pending,
Running,
Inactive
}
private readonly record struct HealthSummary(int Total, int Running, int Pending, int Attention, int Inactive, DateTime? StartedAt);
private sealed class HomeActivity
{
public required DateTime WindowStart { get; init; }
public required int[] OkBuckets { get; init; }
public required int[] ErrorBuckets { get; init; }
public required int TotalRequests { get; init; }
public required double RequestsPerMinute { get; init; }
public required double? ErrorRate { get; init; }
public required TimeSpan? P95 { get; init; }
public required Dictionary<ResourceKey, ResourceActivity> ByResource { get; init; }
public required List<EndpointInsight> SlowestEndpoints { get; init; }
public required List<ResourceActivity> ErrorHotspots { get; init; }
public required List<ResourceActivity> BusiestResources { get; init; }
}
private sealed class ResourceActivity(string name)
{
public string Name { get; } = name;
public int Requests { get; set; }
public int ErrorSpans { get; set; }
public int ErrorLogs { get; set; }
public int TotalErrors => ErrorSpans + ErrorLogs;
}
private sealed record EndpointInsight(string ResourceName, string Label, int Count, TimeSpan P95);
private sealed class HomeTelemetry
{
public static HomeTelemetry Empty { get; } = new()
{
Activity = new HomeActivity
{
WindowStart = DateTime.UtcNow,
OkBuckets = new int[ActivityBucketCount],
ErrorBuckets = new int[ActivityBucketCount],
TotalRequests = 0,
RequestsPerMinute = 0,
ErrorRate = null,
P95 = null,
ByResource = [],
SlowestEndpoints = [],
ErrorHotspots = [],
BusiestResources = []
},
LogCount = 0,
WarningLogCount = 0,
ErrorLogCount = 0,
RecentErrors = [],
TraceCount = 0,
FailedTraceCount = 0,
RecentFailedTraces = [],
InstrumentCount = 0,
MeterCount = 0
};
public required HomeActivity Activity { get; init; }
public required int LogCount { get; init; }
public required int WarningLogCount { get; init; }
public required int ErrorLogCount { get; init; }
public required List<LogSummary> RecentErrors { get; init; }
public required int TraceCount { get; init; }
public required int FailedTraceCount { get; init; }
public required List<TraceSummary> RecentFailedTraces { get; init; }
public required int InstrumentCount { get; init; }
public required int MeterCount { get; init; }
}
}