From 3b86ce7c6d7d5e1a0d0ad0ef8d531e7b2b4ef8ac Mon Sep 17 00:00:00 2001 From: James Gould Date: Mon, 3 Aug 2026 09:14:22 +0100 Subject: [PATCH 01/28] Add health state enum modelled on Azure Monitor --- .../Model/HealthModel/HealthState.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthState.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthState.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthState.cs new file mode 100644 index 00000000000..e685ed4ffbe --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthState.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// The health state of an entity or signal in a health model. +/// +/// +/// +/// These values intentionally mirror the HealthState enum of Azure Monitor health models so a model +/// defined locally in the dashboard can be translated to a Microsoft.CloudHealth/healthmodels deployment. +/// The Azure enum also contains Deleted, which is a service-side lifecycle artifact with no local +/// equivalent, so it is not represented here. +/// +/// +/// See https://learn.microsoft.com/azure/azure-monitor/health-models/concepts. +/// +/// +public enum HealthState +{ + /// No signal has reported yet, or the entity has no signals to evaluate. + Unknown, + + /// All signals are within their expected range. + Healthy, + + /// At least one signal breached its degraded threshold but not its unhealthy threshold. + Degraded, + + /// At least one signal breached its unhealthy threshold. + Unhealthy +} From d9eb9cb07372aebec8dda29d9b7e3c1dad503cb9 Mon Sep 17 00:00:00 2001 From: James Gould Date: Mon, 3 Aug 2026 16:42:05 +0100 Subject: [PATCH 02/28] Add dependency rollup configuration types --- .../HealthModel/DependenciesAggregation.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/DependenciesAggregation.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/DependenciesAggregation.cs b/src/Aspire.Dashboard/Model/HealthModel/DependenciesAggregation.cs new file mode 100644 index 00000000000..c7aa0aba115 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/DependenciesAggregation.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// How an entity aggregates the health of its child entities into a single state. +/// +/// +/// Mirrors signalGroups.dependencies in Azure Monitor health models, which is configured on the +/// parent entity. See https://learn.microsoft.com/azure/azure-monitor/health-models/rollup. +/// +public sealed record DependenciesAggregation +{ + /// The default rollup, where the worst state across all children wins. + public static DependenciesAggregation WorstOf { get; } = new(); + + /// The strategy used to combine child health states. + public DependenciesAggregationType AggregationType { get; init; } = DependenciesAggregationType.WorstOf; + + /// + /// The threshold at which the entity becomes . + /// Only meaningful for the threshold-based aggregation types. When it is not set the entity moves + /// straight from to with no + /// intermediate degraded state, which matches the Azure behaviour when degradedThreshold is omitted. + /// + public double? DegradedThreshold { get; init; } + + /// + /// The threshold at which the entity becomes . + /// Required by the threshold-based aggregation types and must not be set for + /// . + /// + public double? UnhealthyThreshold { get; init; } + + /// Whether thresholds are counts of children or a percentage of them. + public AggregationUnit Unit { get; init; } = AggregationUnit.Absolute; + + /// + /// Whether children in are excluded from the threshold calculation. + /// Defaults to to match the Azure default (the portal's "Ignore unknown" checkbox + /// is selected by default). + /// + public bool IgnoreUnknown { get; init; } = true; +} + +/// +/// The strategy an entity uses to combine the health states of its children. +/// +/// +/// Values match the DependenciesAggregationType enum of the 2026-05-01-preview Azure API version. +/// BestOf exists only in later preview versions that have no Bicep types generated yet, so it is omitted. +/// +public enum DependenciesAggregationType +{ + /// The worst (most severe) child state becomes the parent state. + WorstOf, + + /// + /// The parent degrades when the number or percentage of healthy children falls to or below + /// the threshold. Thresholds read as "at least this many children must be healthy". + /// + MinHealthy, + + /// + /// The parent degrades when the number or percentage of not healthy children reaches or + /// exceeds the threshold. Thresholds read as "no more than this many children may be unhealthy". + /// + MaxNotHealthy +} + +/// +/// Whether an aggregation threshold is an absolute count of entities or a percentage of them. +/// +public enum AggregationUnit +{ + /// The threshold is a count of child entities. + Absolute, + + /// The threshold is a percentage between 0 and 100. + Percentage +} From 9989ecaf5a18fe106c3ff222c4f725dd0f577019 Mon Sep 17 00:00:00 2001 From: James Gould Date: Tue, 4 Aug 2026 10:05:47 +0100 Subject: [PATCH 03/28] Add entity impact for child-side health propagation --- .../Model/HealthModel/EntityImpact.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/EntityImpact.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/EntityImpact.cs b/src/Aspire.Dashboard/Model/HealthModel/EntityImpact.cs new file mode 100644 index 00000000000..446213f9fe7 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/EntityImpact.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// Controls how much of a child entity's health state is propagated to its parents. +/// +/// +/// Mirrors EntityProperties.impact in Azure Monitor health models. Impact is configured on the +/// child and is applied before the parent aggregates its dependencies, so impact and the parent's +/// compose. +/// See https://learn.microsoft.com/azure/azure-monitor/health-models/concepts#impact-child. +/// +public enum EntityImpact +{ + /// The child's state is propagated to the parent unchanged. This is the default. + Standard, + + /// + /// The child can never report worse than to its parent. + /// is not propagated at all and + /// is propagated as . + /// + Limited, + + /// The child never affects its parent. The parent always observes it as healthy. + Suppressed +} From 4c579ae3d8b107185115eeccc6cbfa9610e85747 Mon Sep 17 00:00:00 2001 From: James Gould Date: Tue, 4 Aug 2026 17:31:12 +0100 Subject: [PATCH 04/28] Add health state severity ordering and impact helpers --- .../HealthModel/HealthStateExtensions.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs new file mode 100644 index 00000000000..11030fd819e --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// Health state severity comparison and propagation helpers. +/// +public static class HealthStateExtensions +{ + /// + /// Gets the relative severity of a state, where a higher number is worse. + /// + /// + /// is deliberately the least severe state rather than a + /// middling one. This matches Azure Monitor, where "Unknown = 0 is the lowest severity and can never beat + /// any non-Unknown member" under a worst-of rollup. The practical consequence is that a child that has + /// not reported yet never drags its parent down. + /// + public static int Severity(this HealthState state) => state switch + { + HealthState.Unknown => 0, + HealthState.Healthy => 1, + HealthState.Degraded => 2, + HealthState.Unhealthy => 3, + _ => 0 + }; + + /// Returns the more severe of two states. + public static HealthState WorstOf(HealthState first, HealthState second) + => first.Severity() >= second.Severity() ? first : second; + + /// + /// Returns the most severe state in a sequence, or when it is empty. + /// + public static HealthState WorstOf(IEnumerable states) + { + var worst = HealthState.Unknown; + foreach (var state in states) + { + worst = WorstOf(worst, state); + } + + return worst; + } + + /// + /// Applies a child's to the state it reports to its parents. + /// + /// + /// This runs on the child before the parent aggregates its dependencies, so impact and the parent's + /// aggregation compose rather than override one another. + /// + public static HealthState ApplyImpact(this HealthState state, EntityImpact impact) => impact switch + { + EntityImpact.Standard => state, + + // A limited-impact child can never report worse than degraded. Its own degraded state is swallowed + // entirely so that a partially degraded dependency does not visibly degrade the parent. + EntityImpact.Limited => state switch + { + HealthState.Unhealthy => HealthState.Degraded, + HealthState.Degraded => HealthState.Healthy, + _ => state + }, + + // Azure specifies that a suppressed child is always seen as healthy by its parent, including when + // its own state is unknown. + EntityImpact.Suppressed => HealthState.Healthy, + + _ => state + }; +} From e1511b0afbff5b6d42a5c87c1e6a5dec66644687 Mon Sep 17 00:00:00 2001 From: James Gould Date: Wed, 5 Aug 2026 11:48:33 +0100 Subject: [PATCH 05/28] Add signals with threshold evaluation rules --- .../Model/HealthModel/HealthModelSignal.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs new file mode 100644 index 00000000000..2e6aedb5615 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// The data source a signal reads from. +/// +/// +/// Mirrors the SignalKind discriminator in Azure Monitor health models. Signals produced locally by the +/// dashboard use because, like Azure external signals, their state is reported by the +/// app host rather than computed by the health model service from a metric or query. +/// +public enum SignalKind +{ + /// A platform metric read from an Azure resource. + AzureResourceMetric, + + /// A KQL query run against a Log Analytics workspace. + LogAnalyticsQuery, + + /// A PromQL query run against an Azure Monitor workspace. + PrometheusMetricsQuery, + + /// A state reported by an external producer rather than evaluated by the health model itself. + External +} + +/// +/// The comparison used to test an observed signal value against a threshold. +/// +/// +/// Values match the SignalOperator enum of the 2026-05-01-preview Azure API version. The +/// Dynamic operator is omitted because it relies on Azure-side anomaly detection that has no local equivalent. +/// +public enum SignalOperator +{ + /// The signal breaches when the observed value is greater than the threshold. + GreaterThan, + + /// The signal breaches when the observed value is less than the threshold. + LessThan, + + /// The signal breaches when the observed value is less than or equal to the threshold. + LessThanOrEqual, + + /// The signal breaches when the observed value is greater than or equal to the threshold. + GreaterThanOrEqual, + + /// The signal breaches when the observed value equals the threshold. + Equal, + + /// The signal breaches when the observed value does not equal the threshold. + NotEqual +} + +/// +/// A single comparison that moves a signal into a non-healthy state when it matches. +/// +/// The comparison to apply. +/// The value the observed value is compared against. +public sealed record ThresholdRule(SignalOperator Operator, double Threshold) +{ + /// Determines whether breaches this rule. + public bool IsBreached(double value) => Operator switch + { + SignalOperator.GreaterThan => value > Threshold, + SignalOperator.LessThan => value < Threshold, + SignalOperator.LessThanOrEqual => value <= Threshold, + SignalOperator.GreaterThanOrEqual => value >= Threshold, + SignalOperator.Equal => value == Threshold, + SignalOperator.NotEqual => value != Threshold, + _ => false + }; +} + +/// +/// The thresholds that turn an observed signal value into a . +/// +/// +/// Mirrors EvaluationRule in Azure Monitor health models, where the unhealthy rule is required and the +/// degraded rule is optional. When only is set the signal moves straight from +/// healthy to unhealthy with no intermediate degraded state. +/// +/// The rule that moves the signal to . +/// The optional rule that moves the signal to . +public sealed record EvaluationRule(ThresholdRule UnhealthyRule, ThresholdRule? DegradedRule = null) +{ + /// Evaluates against the rules and returns the resulting state. + public HealthState Evaluate(double value) + { + // The unhealthy rule is checked first because both rules can match at once. For example a rule pair + // of "degraded below 100%, unhealthy below 99%" both match at 98% and unhealthy must win. + if (UnhealthyRule.IsBreached(value)) + { + return HealthState.Unhealthy; + } + + if (DegradedRule?.IsBreached(value) is true) + { + return HealthState.Degraded; + } + + return HealthState.Healthy; + } +} + +/// +/// A single health indicator attached to an entity. +/// +/// +/// An entity's own state is the worst state across all of its signals, which is then combined with the +/// rolled up state of its dependencies. +/// +public sealed record HealthModelSignal +{ + /// The name of the signal. Must be unique within its entity. + public required string Name { get; init; } + + /// The name shown in the UI. Falls back to when not set. + public string? DisplayName { get; init; } + + /// The data source the signal reads from. + public SignalKind Kind { get; init; } = SignalKind.External; + + /// + /// The thresholds applied to . When this is the signal + /// is state-reported and is used directly. + /// + public EvaluationRule? EvaluationRules { get; init; } + + /// The most recent numeric value observed for this signal, if it produces one. + public double? ObservedValue { get; init; } + + /// The unit of , such as Percent or Count. + public string? DataUnit { get; init; } + + /// + /// The state reported directly by the producer. Used when is + /// , which is the case for signals projected from app host health reports. + /// + public HealthState ReportedState { get; init; } = HealthState.Unknown; + + /// Human readable detail about why the signal is in its current state. + public string? Description { get; init; } + + /// The state this signal contributes to its entity. + public HealthState State => EvaluationRules is { } rules && ObservedValue is { } value + ? rules.Evaluate(value) + : ReportedState; +} From 0562f022cac986f14fdd01896379a23e99450f11 Mon Sep 17 00:00:00 2001 From: James Gould Date: Thu, 6 Aug 2026 09:52:18 +0100 Subject: [PATCH 06/28] Add entity, relationship and model definition types --- .../Model/HealthModel/HealthModelEntity.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs new file mode 100644 index 00000000000..34af6155285 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs @@ -0,0 +1,89 @@ +// 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.Immutable; + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// A node in a health model. Represents either a real resource or a logical component such as a code +/// component, a user flow, or a team. +/// +/// +/// Mirrors Microsoft.CloudHealth/healthmodels/entities. Azure has no entity kind discriminator, so +/// whether an entity represents a resource is determined structurally by whether +/// is set. On translation that becomes the presence of an azureResource signal group. +/// +public sealed record HealthModelEntity +{ + /// + /// The name of the entity. Must be unique within the model and is the key used by relationships. + /// + /// + /// Azure constrains entity names to ^[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]$. Names are not + /// validated here because the local model has no such restriction, but keeping to that shape avoids + /// having to rewrite names when the model is translated to Bicep. + /// + public required string Name { get; init; } + + /// The name shown in the UI. Falls back to when not set. + public string? DisplayName { get; init; } + + /// How much of this entity's state is propagated to its parents. + public EntityImpact Impact { get; init; } = EntityImpact.Standard; + + /// + /// The percentage of time the entity is expected to be healthy, between 0 and 100. Informational only + /// in the local model; it maps to healthObjective on translation. + /// + public double? HealthObjective { get; init; } + + /// How this entity combines the health states of its children. + public DependenciesAggregation Dependencies { get; init; } = DependenciesAggregation.WorstOf; + + /// The signals that determine this entity's own state, before dependencies are considered. + public ImmutableArray Signals { get; init; } = []; + + /// The name of the Aspire resource this entity was projected from, when it represents one. + public string? ResourceName { get; init; } + + /// The type of the Aspire resource this entity was projected from, such as Project. + public string? ResourceType { get; init; } + + /// The name shown in the UI for what this entity represents, such as "Container" or "Service". + public string? Category { get; init; } +} + +/// +/// A directed parent-to-child edge in a health model. +/// +/// +/// Azure models relationships as standalone resources with immutable parentEntityName and +/// childEntityName, and carries no health or aggregation configuration on the edge itself. Rollup +/// tuning lives on the two entities instead: on the child and +/// on the parent. +/// +/// The name of the parent entity. +/// The name of the child entity. +public sealed record HealthModelRelationship(string ParentEntityName, string ChildEntityName); + +/// +/// A complete health model: a set of entities and the relationships that connect them. +/// +public sealed record HealthModelDefinition +{ + /// + /// The name of the model. This is also the name of the root entity, matching the Azure behaviour where + /// the root entity is created automatically with the same name as the health model. + /// + public required string Name { get; init; } + + /// The name shown in the UI. Falls back to when not set. + public string? DisplayName { get; init; } + + /// All entities in the model, including the root entity. + public ImmutableArray Entities { get; init; } = []; + + /// The parent-to-child edges connecting . + public ImmutableArray Relationships { get; init; } = []; +} From 25205f393689bc5e8ca39c64ed1231bad79353fe Mon Sep 17 00:00:00 2001 From: James Gould Date: Thu, 6 Aug 2026 15:20:41 +0100 Subject: [PATCH 07/28] Add evaluated snapshot types for rendering --- .../Model/HealthModel/HealthModelSnapshot.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs new file mode 100644 index 00000000000..e6bdf2aa41c --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs @@ -0,0 +1,71 @@ +// 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.Immutable; + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// An entity with its evaluated health state and its place in the model hierarchy. +/// +public sealed class HealthModelNode +{ + /// The entity this node was evaluated from. + public required HealthModelEntity Entity { get; init; } + + /// The final state of the entity, combining its own signals with its dependencies. + public required HealthState State { get; init; } + + /// + /// The worst state across the entity's own signals, or when it has none. + /// Surfaced separately so the UI can show why an entity is unhealthy. + /// + public required HealthState SignalsState { get; init; } + + /// + /// The state contributed by the entity's children after aggregation, or when the + /// entity has no children. + /// + public required HealthState? DependenciesState { get; init; } + + /// The entity's children, already evaluated. + public required ImmutableArray Children { get; init; } + + /// The distance from the root entity. The root itself is zero. + public required int Depth { get; init; } + + /// The unique name of the entity. + public string Name => Entity.Name; + + /// The name to show in the UI. + public string DisplayName => Entity.DisplayName ?? Entity.Name; +} + +/// +/// A fully evaluated health model, ready to render. +/// +public sealed class HealthModelSnapshot +{ + /// An empty model, used before the first resource snapshot arrives. + public static HealthModelSnapshot Empty { get; } = new() + { + Definition = new HealthModelDefinition { Name = "empty" }, + Root = null, + AllNodes = [] + }; + + /// The definition this snapshot was evaluated from. + public required HealthModelDefinition Definition { get; init; } + + /// The root entity of the model, or when the model has no entities. + public required HealthModelNode? Root { get; init; } + + /// + /// Every node in depth-first order. The UI renders the hierarchy as an indented flat list, so this + /// ordering is the render order. + /// + public required ImmutableArray AllNodes { get; init; } + + /// The overall state of the model. + public HealthState State => Root?.State ?? HealthState.Unknown; +} From cfe184f9658d52a4c433a77651dd2b9f2d80bf60 Mon Sep 17 00:00:00 2001 From: James Gould Date: Fri, 7 Aug 2026 11:07:55 +0100 Subject: [PATCH 08/28] Add evaluator implementing the Azure rollup pipeline --- .../Model/HealthModel/HealthModelEvaluator.cs | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs new file mode 100644 index 00000000000..da9aa37f543 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs @@ -0,0 +1,193 @@ +// 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.Immutable; + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// Evaluates a into a by resolving +/// each entity's signals and rolling child health up through the model. +/// +/// +/// The rollup reproduces the Azure Monitor pipeline: each signal is evaluated to a state, the entity takes +/// the worst state across its own signals, each child's state is rewritten by its own +/// , those results are combined using the parent's +/// , and finally the entity's own state and its aggregated dependency +/// state are combined worst-of. +/// See https://learn.microsoft.com/azure/azure-monitor/health-models/rollup. +/// +public static class HealthModelEvaluator +{ + /// Evaluates a model definition. + public static HealthModelSnapshot Evaluate(HealthModelDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + if (definition.Entities.Length == 0) + { + return new HealthModelSnapshot { Definition = definition, Root = null, AllNodes = [] }; + } + + var entitiesByName = definition.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); + + var childNamesByParent = definition.Relationships + .GroupBy(r => r.ParentEntityName, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(r => r.ChildEntityName).ToArray(), StringComparer.Ordinal); + + // The root is the entity named after the model, matching the Azure convention where the root entity + // is created automatically using the health model's own name. Fall back to any entity that is never + // a child so a hand-built model without that convention still renders. + var root = entitiesByName.TryGetValue(definition.Name, out var namedRoot) + ? namedRoot + : FindImplicitRoot(definition); + + if (root is null) + { + return new HealthModelSnapshot { Definition = definition, Root = null, AllNodes = [] }; + } + + var allNodes = ImmutableArray.CreateBuilder(); + + // Entities can legitimately have multiple parents, so an entity may be visited more than once. + // The visiting set only guards against cycles on the current path, which would otherwise recurse forever. + var visiting = new HashSet(StringComparer.Ordinal); + var rootNode = EvaluateEntity(root, depth: 0); + + return new HealthModelSnapshot + { + Definition = definition, + Root = rootNode, + AllNodes = allNodes.ToImmutable() + }; + + HealthModelNode EvaluateEntity(HealthModelEntity entity, int depth) + { + // Reserve this node's slot before recursing so children are appended after their parent and the + // flattened list comes out in depth-first render order. + var nodeIndex = allNodes.Count; + allNodes.Add(null!); + + var children = ImmutableArray.Empty; + + if (childNamesByParent.TryGetValue(entity.Name, out var childNames) && visiting.Add(entity.Name)) + { + try + { + var builder = ImmutableArray.CreateBuilder(childNames.Length); + foreach (var childName in childNames) + { + if (entitiesByName.TryGetValue(childName, out var child) && !visiting.Contains(childName)) + { + builder.Add(EvaluateEntity(child, depth + 1)); + } + } + + children = builder.ToImmutable(); + } + finally + { + visiting.Remove(entity.Name); + } + } + + var signalsState = entity.Signals.Length == 0 + ? HealthState.Unknown + : HealthStateExtensions.WorstOf(entity.Signals.Select(s => s.State)); + + HealthState? dependenciesState = children.Length == 0 + ? null + : AggregateDependencies(entity.Dependencies, children); + + // Unknown is the least severe state, so an entity with no signals simply inherits its dependency + // state and an entity with no children is driven entirely by its own signals. No special casing needed. + var state = HealthStateExtensions.WorstOf(signalsState, dependenciesState ?? HealthState.Unknown); + + var node = new HealthModelNode + { + Entity = entity, + State = state, + SignalsState = signalsState, + DependenciesState = dependenciesState, + Children = children, + Depth = depth + }; + + allNodes[nodeIndex] = node; + return node; + } + } + + /// + /// Combines the states of an entity's children into the single state they contribute to their parent. + /// + internal static HealthState AggregateDependencies(DependenciesAggregation aggregation, ImmutableArray children) + { + // Each child's state is first rewritten by its own impact, then fed into the parent's aggregation. + var memberStates = children.Select(c => c.State.ApplyImpact(c.Entity.Impact)); + + if (aggregation.AggregationType == DependenciesAggregationType.WorstOf) + { + return HealthStateExtensions.WorstOf(memberStates); + } + + var members = aggregation.IgnoreUnknown + ? memberStates.Where(s => s != HealthState.Unknown).ToList() + : memberStates.ToList(); + + if (members.Count == 0) + { + return HealthState.Unknown; + } + + var healthyCount = members.Count(s => s == HealthState.Healthy); + + // MinHealthy counts what is working, MaxNotHealthy counts what is broken. The two therefore breach + // in opposite directions, which is handled by IsBreached below. + var measured = aggregation.AggregationType == DependenciesAggregationType.MinHealthy + ? healthyCount + : members.Count - healthyCount; + + var value = aggregation.Unit == AggregationUnit.Percentage + ? measured * 100d / members.Count + : measured; + + if (aggregation.UnhealthyThreshold is { } unhealthyThreshold && IsBreached(value, unhealthyThreshold)) + { + return HealthState.Unhealthy; + } + + if (aggregation.DegradedThreshold is { } degradedThreshold && IsBreached(value, degradedThreshold)) + { + return HealthState.Degraded; + } + + return HealthState.Healthy; + + bool IsBreached(double measuredValue, double threshold) => aggregation.AggregationType switch + { + // "At least N children must be healthy" breaches once the healthy count falls to or below N. + DependenciesAggregationType.MinHealthy => measuredValue <= threshold, + // "No more than N children may be unhealthy" breaches once the not-healthy count reaches N. + DependenciesAggregationType.MaxNotHealthy => measuredValue >= threshold, + _ => false + }; + } + + private static HealthModelEntity? FindImplicitRoot(HealthModelDefinition definition) + { + var childNames = definition.Relationships.Select(r => r.ChildEntityName).ToHashSet(StringComparer.Ordinal); + + foreach (var entity in definition.Entities) + { + if (!childNames.Contains(entity.Name)) + { + return entity; + } + } + + // Every entity is a child of something, which means the model is a cycle. Fall back to the first + // entity so the UI still renders something rather than failing. + return definition.Entities.Length > 0 ? definition.Entities[0] : null; + } +} From d3c49d67f02b3ccb3495d40b0dfee781ea00560b Mon Sep 17 00:00:00 2001 From: James Gould Date: Sat, 8 Aug 2026 10:33:09 +0100 Subject: [PATCH 09/28] Add tests for rollup, impact and threshold semantics --- .../Model/HealthModelEvaluatorTests.cs | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs diff --git a/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs b/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs new file mode 100644 index 00000000000..10c00300986 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs @@ -0,0 +1,350 @@ +// 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.Immutable; +using Aspire.Dashboard.Model.HealthModel; +using Xunit; + +namespace Aspire.Dashboard.Tests.Model; + +public class HealthModelEvaluatorTests +{ + [Fact] + public void Evaluate_EntityWithNoSignalsOrChildren_IsUnknown() + { + var definition = CreateModel([Entity("root")], []); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Equal(HealthState.Unknown, snapshot.State); + } + + [Theory] + [InlineData(HealthState.Healthy, HealthState.Healthy, HealthState.Healthy)] + [InlineData(HealthState.Healthy, HealthState.Degraded, HealthState.Degraded)] + [InlineData(HealthState.Degraded, HealthState.Unhealthy, HealthState.Unhealthy)] + [InlineData(HealthState.Unhealthy, HealthState.Unknown, HealthState.Unhealthy)] + public void Evaluate_EntitySignals_TakesWorstSignalState(HealthState first, HealthState second, HealthState expected) + { + var definition = CreateModel([Entity("root", signals: [Signal("a", first), Signal("b", second)])], []); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Equal(expected, snapshot.State); + } + + [Fact] + public void Evaluate_UnknownChild_DoesNotDragParentDown() + { + // Unknown is the least severe state in Azure Monitor, so a child that has not reported yet must + // leave a healthy parent healthy rather than making the whole model look broken. + var definition = CreateModel( + [ + Entity("root"), + Entity("reporting", signals: [Signal("a", HealthState.Healthy)]), + Entity("silent") + ], + [("root", "reporting"), ("root", "silent")]); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Equal(HealthState.Healthy, snapshot.State); + } + + [Fact] + public void Evaluate_WorstOfRollup_PropagatesWorstChild() + { + var definition = CreateModel( + [ + Entity("root"), + Entity("a", signals: [Signal("s", HealthState.Healthy)]), + Entity("b", signals: [Signal("s", HealthState.Unhealthy)]) + ], + [("root", "a"), ("root", "b")]); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Equal(HealthState.Unhealthy, snapshot.State); + } + + [Fact] + public void Evaluate_EntityCombinesOwnSignalsWithDependencies() + { + // The parent's own signal is degraded while its child is unhealthy. The final state is the worst + // of the two, not just whichever was evaluated last. + var definition = CreateModel( + [ + Entity("root", signals: [Signal("s", HealthState.Degraded)]), + Entity("child", signals: [Signal("s", HealthState.Unhealthy)]) + ], + [("root", "child")]); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Equal(HealthState.Unhealthy, snapshot.State); + Assert.Equal(HealthState.Degraded, snapshot.Root!.SignalsState); + Assert.Equal(HealthState.Unhealthy, snapshot.Root.DependenciesState); + } + + [Theory] + [InlineData(EntityImpact.Standard, HealthState.Unhealthy)] + [InlineData(EntityImpact.Limited, HealthState.Degraded)] + [InlineData(EntityImpact.Suppressed, HealthState.Healthy)] + public void Evaluate_ChildImpact_RewritesStateSeenByParent(EntityImpact impact, HealthState expected) + { + var definition = CreateModel( + [ + Entity("root"), + Entity("child", impact: impact, signals: [Signal("s", HealthState.Unhealthy)]) + ], + [("root", "child")]); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Equal(expected, snapshot.State); + + // The child itself still reports its true state. Only what the parent sees is rewritten. + Assert.Equal(HealthState.Unhealthy, snapshot.Root!.Children.Single().State); + } + + [Fact] + public void Evaluate_LimitedImpact_SwallowsDegradedEntirely() + { + var definition = CreateModel( + [ + Entity("root"), + Entity("child", impact: EntityImpact.Limited, signals: [Signal("s", HealthState.Degraded)]) + ], + [("root", "child")]); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Equal(HealthState.Healthy, snapshot.State); + } + + [Theory] + // Three children, unhealthy once two or more are broken, degraded once one is broken. + [InlineData(0, HealthState.Healthy)] + [InlineData(1, HealthState.Degraded)] + [InlineData(2, HealthState.Unhealthy)] + [InlineData(3, HealthState.Unhealthy)] + public void Evaluate_MaxNotHealthyRollup_BreachesWhenNotHealthyCountReachesThreshold(int unhealthyCount, HealthState expected) + { + var entities = new List + { + Entity("root", dependencies: new DependenciesAggregation + { + AggregationType = DependenciesAggregationType.MaxNotHealthy, + DegradedThreshold = 1, + UnhealthyThreshold = 2 + }) + }; + + var relationships = new List<(string, string)>(); + for (var i = 0; i < 3; i++) + { + var state = i < unhealthyCount ? HealthState.Unhealthy : HealthState.Healthy; + entities.Add(Entity($"child{i}", signals: [Signal("s", state)])); + relationships.Add(("root", $"child{i}")); + } + + var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], relationships)); + + Assert.Equal(expected, snapshot.State); + } + + [Theory] + // Four children where at least three must be healthy. Degrades at three, fails at two. + [InlineData(4, HealthState.Healthy)] + [InlineData(3, HealthState.Degraded)] + [InlineData(2, HealthState.Unhealthy)] + public void Evaluate_MinHealthyRollup_BreachesWhenHealthyCountFallsToThreshold(int healthyCount, HealthState expected) + { + var entities = new List + { + Entity("root", dependencies: new DependenciesAggregation + { + AggregationType = DependenciesAggregationType.MinHealthy, + DegradedThreshold = 3, + UnhealthyThreshold = 2 + }) + }; + + var relationships = new List<(string, string)>(); + for (var i = 0; i < 4; i++) + { + var state = i < healthyCount ? HealthState.Healthy : HealthState.Unhealthy; + entities.Add(Entity($"child{i}", signals: [Signal("s", state)])); + relationships.Add(("root", $"child{i}")); + } + + var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], relationships)); + + Assert.Equal(expected, snapshot.State); + } + + [Fact] + public void Evaluate_PercentageUnit_UsesShareOfChildren() + { + // Two of four children unhealthy is 50%, which reaches the 50% unhealthy threshold. + var entities = new List + { + Entity("root", dependencies: new DependenciesAggregation + { + AggregationType = DependenciesAggregationType.MaxNotHealthy, + UnhealthyThreshold = 50, + Unit = AggregationUnit.Percentage + }) + }; + + var relationships = new List<(string, string)>(); + for (var i = 0; i < 4; i++) + { + var state = i < 2 ? HealthState.Unhealthy : HealthState.Healthy; + entities.Add(Entity($"child{i}", signals: [Signal("s", state)])); + relationships.Add(("root", $"child{i}")); + } + + var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], relationships)); + + Assert.Equal(HealthState.Unhealthy, snapshot.State); + } + + [Fact] + public void Evaluate_IgnoreUnknown_ExcludesUnknownChildrenFromThreshold() + { + // One unhealthy and one unknown child. With unknown ignored the denominator is one, so the single + // unhealthy child is 100% and breaches. Without ignoring it the share would only be 50%. + var entities = new List + { + Entity("root", dependencies: new DependenciesAggregation + { + AggregationType = DependenciesAggregationType.MaxNotHealthy, + UnhealthyThreshold = 100, + Unit = AggregationUnit.Percentage, + IgnoreUnknown = true + }), + Entity("broken", signals: [Signal("s", HealthState.Unhealthy)]), + Entity("silent") + }; + + var snapshot = HealthModelEvaluator.Evaluate( + CreateModel([.. entities], [("root", "broken"), ("root", "silent")])); + + Assert.Equal(HealthState.Unhealthy, snapshot.State); + } + + [Fact] + public void Evaluate_ThresholdRollupWithOnlyUnknownChildren_IsUnknown() + { + var entities = new List + { + Entity("root", dependencies: new DependenciesAggregation + { + AggregationType = DependenciesAggregationType.MaxNotHealthy, + UnhealthyThreshold = 1 + }), + Entity("silent") + }; + + var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], [("root", "silent")])); + + Assert.Equal(HealthState.Unknown, snapshot.State); + } + + [Fact] + public void Evaluate_FlattensNodesInDepthFirstOrderWithDepth() + { + var definition = CreateModel( + [Entity("root"), Entity("group"), Entity("leaf"), Entity("sibling")], + [("root", "group"), ("group", "leaf"), ("root", "sibling")]); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Collection(snapshot.AllNodes, + n => { Assert.Equal("root", n.Name); Assert.Equal(0, n.Depth); }, + n => { Assert.Equal("group", n.Name); Assert.Equal(1, n.Depth); }, + n => { Assert.Equal("leaf", n.Name); Assert.Equal(2, n.Depth); }, + n => { Assert.Equal("sibling", n.Name); Assert.Equal(1, n.Depth); }); + } + + [Fact] + public void Evaluate_CyclicRelationships_DoesNotRecurseForever() + { + var definition = CreateModel( + [Entity("root"), Entity("a"), Entity("b")], + [("root", "a"), ("a", "b"), ("b", "a")]); + + var snapshot = HealthModelEvaluator.Evaluate(definition); + + Assert.Collection(snapshot.AllNodes, + n => Assert.Equal("root", n.Name), + n => Assert.Equal("a", n.Name), + n => Assert.Equal("b", n.Name)); + } + + [Fact] + public void Evaluate_NoEntities_ReturnsEmptySnapshot() + { + var snapshot = HealthModelEvaluator.Evaluate(new HealthModelDefinition { Name = "root" }); + + Assert.Null(snapshot.Root); + Assert.Empty(snapshot.AllNodes); + Assert.Equal(HealthState.Unknown, snapshot.State); + } + + [Fact] + public void EvaluationRule_UnhealthyWins_WhenBothRulesMatch() + { + var rule = new EvaluationRule( + UnhealthyRule: new ThresholdRule(SignalOperator.LessThan, 99), + DegradedRule: new ThresholdRule(SignalOperator.LessThan, 100)); + + Assert.Equal(HealthState.Healthy, rule.Evaluate(100)); + Assert.Equal(HealthState.Degraded, rule.Evaluate(99.5)); + Assert.Equal(HealthState.Unhealthy, rule.Evaluate(98)); + } + + [Fact] + public void Signal_WithEvaluationRules_PrefersObservedValueOverReportedState() + { + var signal = new HealthModelSignal + { + Name = "availability", + Kind = SignalKind.AzureResourceMetric, + ObservedValue = 50, + ReportedState = HealthState.Healthy, + EvaluationRules = new EvaluationRule(new ThresholdRule(SignalOperator.LessThan, 99)) + }; + + Assert.Equal(HealthState.Unhealthy, signal.State); + } + + private static HealthModelDefinition CreateModel(ImmutableArray entities, IEnumerable<(string Parent, string Child)> relationships) + { + return new HealthModelDefinition + { + Name = "root", + Entities = entities, + Relationships = [.. relationships.Select(r => new HealthModelRelationship(r.Parent, r.Child))] + }; + } + + private static HealthModelEntity Entity( + string name, + EntityImpact impact = EntityImpact.Standard, + DependenciesAggregation? dependencies = null, + ImmutableArray? signals = null) + { + return new HealthModelEntity + { + Name = name, + Impact = impact, + Dependencies = dependencies ?? DependenciesAggregation.WorstOf, + Signals = signals ?? [] + }; + } + + private static HealthModelSignal Signal(string name, HealthState state) + => new() { Name = name, ReportedState = state }; +} From b042ed051b8d01fd7a682338ebf97a672018f9b9 Mon Sep 17 00:00:00 2001 From: James Gould Date: Sun, 9 Aug 2026 12:19:44 +0100 Subject: [PATCH 10/28] Project Aspire resources into a sample health model --- .../HealthModel/AspireHealthModelBuilder.cs | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs new file mode 100644 index 00000000000..2a153cbf948 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs @@ -0,0 +1,208 @@ +// 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.Immutable; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// Projects the live Aspire application model into a . +/// +/// +/// +/// This is the sample model for the MVP. It is deliberately small and declarative so it is easy to change +/// while the shape of the feature settles. It builds two logical entities under the application root: +/// +/// +/// aspire-app-health (root, worst-of rollup) +/// |- services (projects and executables, standard impact) +/// |- infrastructure (containers, limited impact, tolerates one unhealthy member) +/// +/// +/// The two logical entities exist to exercise the parts of the Azure model that are not obvious: the root +/// uses a plain worst-of rollup, while infrastructure combines a threshold rollup with limited impact +/// so a single broken container degrades the application rather than failing it outright. +/// +/// +public static class AspireHealthModelBuilder +{ + /// The name of the health model, which is also the name of its root entity. + public const string RootEntityName = "aspire-app-health"; + + /// The logical entity that groups projects and executables. + public const string ServicesEntityName = "services"; + + /// The logical entity that groups containers. + public const string InfrastructureEntityName = "infrastructure"; + + /// The name of the signal projected from a resource's lifecycle state. + public const string ResourceStateSignalName = "resource-state"; + + /// + /// Builds the sample model from a set of resources. + /// + /// The resources currently known to the dashboard. + public static HealthModelDefinition Build(IEnumerable resources) + { + ArgumentNullException.ThrowIfNull(resources); + + var entities = ImmutableArray.CreateBuilder(); + var relationships = ImmutableArray.CreateBuilder(); + + entities.Add(new HealthModelEntity + { + Name = RootEntityName, + DisplayName = "Application", + Category = "Application", + Dependencies = DependenciesAggregation.WorstOf + }); + + entities.Add(new HealthModelEntity + { + Name = ServicesEntityName, + DisplayName = "Services", + Category = "Logical component", + Dependencies = DependenciesAggregation.WorstOf + }); + + entities.Add(new HealthModelEntity + { + Name = InfrastructureEntityName, + DisplayName = "Infrastructure", + Category = "Logical component", + + // Infrastructure is backing services rather than the app itself, so a total failure here is + // reported to the application as degraded rather than unhealthy. + Impact = EntityImpact.Limited, + + // Tolerate a single unhealthy container before the group itself reports a problem. This is the + // "4 VMs, tolerate 1 offline" pattern from the Azure docs expressed as a not-healthy limit. + Dependencies = new DependenciesAggregation + { + AggregationType = DependenciesAggregationType.MaxNotHealthy, + DegradedThreshold = 1, + UnhealthyThreshold = 2, + Unit = AggregationUnit.Absolute + } + }); + + relationships.Add(new HealthModelRelationship(RootEntityName, ServicesEntityName)); + relationships.Add(new HealthModelRelationship(RootEntityName, InfrastructureEntityName)); + + foreach (var resource in resources.OrderBy(r => r.Name, StringComparers.ResourceName)) + { + if (resource.IsResourceHidden(showHiddenResources: false)) + { + continue; + } + + var parentName = GetParentEntityName(resource.ResourceType); + if (parentName is null) + { + continue; + } + + entities.Add(CreateResourceEntity(resource)); + relationships.Add(new HealthModelRelationship(parentName, GetEntityName(resource))); + } + + return new HealthModelDefinition + { + Name = RootEntityName, + DisplayName = "Application health", + Entities = entities.ToImmutable(), + Relationships = relationships.ToImmutable() + }; + } + + /// + /// Gets the entity name used for a resource. + /// + /// + /// Uses the resource's persistent key rather than its name because the name includes a randomly + /// generated suffix that changes every time the app host restarts, which would churn entity identity + /// across restarts and break any deployed model that references it. + /// + public static string GetEntityName(ResourceViewModel resource) + { + ArgumentNullException.ThrowIfNull(resource); + + return resource.PersistentKey; + } + + private static string? GetParentEntityName(string resourceType) => resourceType switch + { + KnownResourceTypes.Project or KnownResourceTypes.Executable => ServicesEntityName, + KnownResourceTypes.Container or KnownResourceTypes.ContainerExec => InfrastructureEntityName, + + // Parameters, connection strings and external services have no runtime health of their own, so they + // are left out of the MVP model rather than added as permanently unknown entities. + _ => null + }; + + private static HealthModelEntity CreateResourceEntity(ResourceViewModel resource) + { + var signals = ImmutableArray.CreateBuilder(resource.HealthReports.Length + 1); + + signals.Add(new HealthModelSignal + { + Name = ResourceStateSignalName, + DisplayName = "Resource state", + Kind = SignalKind.External, + ReportedState = MapResourceState(resource.KnownState), + Description = resource.State + }); + + foreach (var report in resource.HealthReports) + { + signals.Add(new HealthModelSignal + { + Name = report.Name, + DisplayName = report.Name, + Kind = SignalKind.External, + ReportedState = MapHealthStatus(report.HealthStatus), + Description = report.Description ?? report.ExceptionText + }); + } + + return new HealthModelEntity + { + Name = GetEntityName(resource), + DisplayName = resource.DisplayName, + Category = resource.ResourceType, + ResourceName = resource.Name, + ResourceType = resource.ResourceType, + Signals = signals.ToImmutable() + }; + } + + /// + /// Maps an Aspire resource lifecycle state to a health state. + /// + /// + /// Transient states map to rather than to a non-healthy state. Because + /// unknown is the least severe state under a worst-of rollup, a service that is still starting does not + /// make the whole application look broken. + /// + internal static HealthState MapResourceState(KnownResourceState? state) => state switch + { + KnownResourceState.Running or KnownResourceState.Finished => HealthState.Healthy, + + KnownResourceState.FailedToStart + or KnownResourceState.Exited + or KnownResourceState.RuntimeUnhealthy + or KnownResourceState.ValueMissing => HealthState.Unhealthy, + + _ => HealthState.Unknown + }; + + /// Maps a health check result to a health state. + internal static HealthState MapHealthStatus(HealthStatus? status) => status switch + { + HealthStatus.Healthy => HealthState.Healthy, + HealthStatus.Degraded => HealthState.Degraded, + HealthStatus.Unhealthy => HealthState.Unhealthy, + _ => HealthState.Unknown + }; +} From ca1509225bcf926b40731e65b2b8dcc314219166 Mon Sep 17 00:00:00 2001 From: James Gould Date: Sun, 9 Aug 2026 18:02:57 +0100 Subject: [PATCH 11/28] Add tests for the Aspire resource projection --- .../Model/AspireHealthModelBuilderTests.cs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs diff --git a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs new file mode 100644 index 00000000000..e93e256772a --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs @@ -0,0 +1,177 @@ +// 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.Model; +using Aspire.Dashboard.Model.HealthModel; +using Aspire.Tests.Shared.DashboardModel; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +namespace Aspire.Dashboard.Tests.Model; + +public class AspireHealthModelBuilderTests +{ + [Fact] + public void Build_NoResources_StillProducesLogicalEntities() + { + var definition = AspireHealthModelBuilder.Build([]); + + Assert.Collection(definition.Entities, + e => Assert.Equal(AspireHealthModelBuilder.RootEntityName, e.Name), + e => Assert.Equal(AspireHealthModelBuilder.ServicesEntityName, e.Name), + e => Assert.Equal(AspireHealthModelBuilder.InfrastructureEntityName, e.Name)); + + Assert.Collection(definition.Relationships, + r => Assert.Equal(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.ServicesEntityName), r), + r => Assert.Equal(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.InfrastructureEntityName), r)); + } + + [Fact] + public void Build_ProjectsAndContainers_AreGroupedUnderDifferentParents() + { + var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); + var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running); + + var definition = AspireHealthModelBuilder.Build([project, container]); + + var projectEntityName = AspireHealthModelBuilder.GetEntityName(project); + var containerEntityName = AspireHealthModelBuilder.GetEntityName(container); + + Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.ServicesEntityName, projectEntityName), definition.Relationships); + Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.InfrastructureEntityName, containerEntityName), definition.Relationships); + } + + [Fact] + public void Build_ResourceWithoutRuntimeHealth_IsExcluded() + { + var parameter = ModelTestHelpers.CreateResource(resourceName: "secret", resourceType: KnownResourceTypes.Parameter, state: KnownResourceState.Running); + + var definition = AspireHealthModelBuilder.Build([parameter]); + + Assert.Collection(definition.Entities, + e => Assert.Equal(AspireHealthModelBuilder.RootEntityName, e.Name), + e => Assert.Equal(AspireHealthModelBuilder.ServicesEntityName, e.Name), + e => Assert.Equal(AspireHealthModelBuilder.InfrastructureEntityName, e.Name)); + } + + [Fact] + public void Build_HiddenResource_IsExcluded() + { + var hidden = ModelTestHelpers.CreateResource(resourceName: "hidden", resourceType: KnownResourceTypes.Container, hidden: true); + + var definition = AspireHealthModelBuilder.Build([hidden]); + + Assert.Collection(definition.Entities, + e => Assert.Equal(AspireHealthModelBuilder.RootEntityName, e.Name), + e => Assert.Equal(AspireHealthModelBuilder.ServicesEntityName, e.Name), + e => Assert.Equal(AspireHealthModelBuilder.InfrastructureEntityName, e.Name)); + } + + [Fact] + public void Build_ResourceEntity_HasStateSignalAndOneSignalPerHealthReport() + { + var resource = ModelTestHelpers.CreateResource( + resourceName: "api", + resourceType: KnownResourceTypes.Project, + state: KnownResourceState.Running, + healthReports: + [ + new HealthReportViewModel("live", HealthStatus.Healthy, "All good", null), + new HealthReportViewModel("ready", HealthStatus.Degraded, "Warming up", null) + ]); + + var definition = AspireHealthModelBuilder.Build([resource]); + var entity = Assert.Single(definition.Entities, e => e.ResourceName == "api"); + + Assert.Collection(entity.Signals, + s => + { + Assert.Equal(AspireHealthModelBuilder.ResourceStateSignalName, s.Name); + Assert.Equal(HealthState.Healthy, s.State); + }, + s => + { + Assert.Equal("live", s.Name); + Assert.Equal(HealthState.Healthy, s.State); + Assert.Equal("All good", s.Description); + }, + s => + { + Assert.Equal("ready", s.Name); + Assert.Equal(HealthState.Degraded, s.State); + Assert.Equal("Warming up", s.Description); + }); + } + + [Fact] + public void Build_EntityName_IsStableAcrossAppHostRestarts() + { + // Resource names carry a random suffix that changes on every app host restart, so entity identity + // must come from the persistent key instead. + var first = ModelTestHelpers.CreateResource(resourceName: "api-abcdefgh", displayName: "api", resourceType: KnownResourceTypes.Project); + var second = ModelTestHelpers.CreateResource(resourceName: "api-ijklmnop", displayName: "api", resourceType: KnownResourceTypes.Project); + + Assert.Equal(AspireHealthModelBuilder.GetEntityName(first), AspireHealthModelBuilder.GetEntityName(second)); + } + + [Theory] + [InlineData(KnownResourceState.Running, HealthState.Healthy)] + [InlineData(KnownResourceState.Finished, HealthState.Healthy)] + [InlineData(KnownResourceState.FailedToStart, HealthState.Unhealthy)] + [InlineData(KnownResourceState.Exited, HealthState.Unhealthy)] + [InlineData(KnownResourceState.RuntimeUnhealthy, HealthState.Unhealthy)] + [InlineData(KnownResourceState.ValueMissing, HealthState.Unhealthy)] + [InlineData(KnownResourceState.Starting, HealthState.Unknown)] + [InlineData(KnownResourceState.Waiting, HealthState.Unknown)] + [InlineData(KnownResourceState.Stopping, HealthState.Unknown)] + public void MapResourceState_MapsLifecycleStates(KnownResourceState state, HealthState expected) + { + Assert.Equal(expected, AspireHealthModelBuilder.MapResourceState(state)); + } + + [Fact] + public void MapResourceState_NullState_IsUnknown() + { + Assert.Equal(HealthState.Unknown, AspireHealthModelBuilder.MapResourceState(null)); + } + + [Theory] + [InlineData(HealthStatus.Healthy, HealthState.Healthy)] + [InlineData(HealthStatus.Degraded, HealthState.Degraded)] + [InlineData(HealthStatus.Unhealthy, HealthState.Unhealthy)] + public void MapHealthStatus_MapsHealthCheckResults(HealthStatus status, HealthState expected) + { + Assert.Equal(expected, AspireHealthModelBuilder.MapHealthStatus(status)); + } + + [Fact] + public void BuildAndEvaluate_UnhealthyProject_FailsApplication() + { + var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.FailedToStart); + + var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project])); + + Assert.Equal(HealthState.Unhealthy, snapshot.State); + } + + [Fact] + public void BuildAndEvaluate_AllResourcesRunning_IsHealthy() + { + var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); + var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running); + + var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project, container])); + + Assert.Equal(HealthState.Healthy, snapshot.State); + } + + [Fact] + public void BuildAndEvaluate_StartingResource_LeavesApplicationUnknownRatherThanUnhealthy() + { + var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Starting); + + var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project])); + + Assert.Equal(HealthState.Unknown, snapshot.State); + } +} From 4a054e98c407eae38718864d65791f86b1919c19 Mon Sep 17 00:00:00 2001 From: James Gould Date: Mon, 10 Aug 2026 14:26:31 +0100 Subject: [PATCH 12/28] Include custom resource types and external services in the model --- .../HealthModel/AspireHealthModelBuilder.cs | 20 +++++++++---- .../Model/AspireHealthModelBuilderTests.cs | 29 ++++++++++++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs index 2a153cbf948..d02d258084c 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs @@ -133,12 +133,20 @@ public static string GetEntityName(ResourceViewModel resource) private static string? GetParentEntityName(string resourceType) => resourceType switch { - KnownResourceTypes.Project or KnownResourceTypes.Executable => ServicesEntityName, - KnownResourceTypes.Container or KnownResourceTypes.ContainerExec => InfrastructureEntityName, - - // Parameters, connection strings and external services have no runtime health of their own, so they - // are left out of the MVP model rather than added as permanently unknown entities. - _ => null + // Parameters and connection strings are configuration values resolved at startup. They have no + // runtime health of their own, so including them would add permanently unknown entities. + KnownResourceTypes.Parameter or KnownResourceTypes.ConnectionString => null, + + // Containers and external services are things the application depends on rather than the + // application itself, so they roll up through the limited-impact infrastructure entity. + KnownResourceTypes.Container + or KnownResourceTypes.ContainerExec + or KnownResourceTypes.ExternalService => InfrastructureEntityName, + + // Projects, executables and custom resource types are all treated as application services. Falling + // through by default rather than listing known types means a custom resource with health checks + // still appears in the model. + _ => ServicesEntityName }; private static HealthModelEntity CreateResourceEntity(ResourceViewModel resource) diff --git a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs index e93e256772a..814bed6601d 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs @@ -45,8 +45,9 @@ public void Build_ProjectsAndContainers_AreGroupedUnderDifferentParents() public void Build_ResourceWithoutRuntimeHealth_IsExcluded() { var parameter = ModelTestHelpers.CreateResource(resourceName: "secret", resourceType: KnownResourceTypes.Parameter, state: KnownResourceState.Running); + var connectionString = ModelTestHelpers.CreateResource(resourceName: "conn", resourceType: KnownResourceTypes.ConnectionString, state: KnownResourceState.Running); - var definition = AspireHealthModelBuilder.Build([parameter]); + var definition = AspireHealthModelBuilder.Build([parameter, connectionString]); Assert.Collection(definition.Entities, e => Assert.Equal(AspireHealthModelBuilder.RootEntityName, e.Name), @@ -54,6 +55,32 @@ public void Build_ResourceWithoutRuntimeHealth_IsExcluded() e => Assert.Equal(AspireHealthModelBuilder.InfrastructureEntityName, e.Name)); } + [Fact] + public void Build_CustomResourceType_IsGroupedUnderServices() + { + // Custom resource types can carry health checks, so they must appear in the model rather than being + // dropped because they are not a known type. + var custom = ModelTestHelpers.CreateResource(resourceName: "widget", resourceType: "Test Resource", state: KnownResourceState.Running); + + var definition = AspireHealthModelBuilder.Build([custom]); + + Assert.Contains( + new HealthModelRelationship(AspireHealthModelBuilder.ServicesEntityName, AspireHealthModelBuilder.GetEntityName(custom)), + definition.Relationships); + } + + [Fact] + public void Build_ExternalService_IsGroupedUnderInfrastructure() + { + var external = ModelTestHelpers.CreateResource(resourceName: "api-gateway", resourceType: KnownResourceTypes.ExternalService, state: KnownResourceState.Running); + + var definition = AspireHealthModelBuilder.Build([external]); + + Assert.Contains( + new HealthModelRelationship(AspireHealthModelBuilder.InfrastructureEntityName, AspireHealthModelBuilder.GetEntityName(external)), + definition.Relationships); + } + [Fact] public void Build_HiddenResource_IsExcluded() { From 27c1cdf79ef2da0efadb98f35d04cb6bddc35f3d Mon Sep 17 00:00:00 2001 From: James Gould Date: Tue, 11 Aug 2026 09:41:16 +0100 Subject: [PATCH 13/28] Add health model route and localized page strings --- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 12 + .../Resources/HealthModel.Designer.cs | 297 ++++++++++++++++++ .../Resources/HealthModel.resx | 202 ++++++++++++ .../Resources/xlf/HealthModel.cs.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.de.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.es.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.fr.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.it.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.ja.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.ko.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.pl.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.pt-BR.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.ru.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.tr.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.zh-Hans.xlf | 137 ++++++++ .../Resources/xlf/HealthModel.zh-Hant.xlf | 137 ++++++++ src/Shared/DashboardUrls.cs | 12 + 17 files changed, 2304 insertions(+) create mode 100644 src/Aspire.Dashboard/Resources/HealthModel.Designer.cs create mode 100644 src/Aspire.Dashboard/Resources/HealthModel.resx create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf create mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index b9d79ed8edb..f66bc9c90fd 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -162,6 +162,11 @@ True Reconnect.resx + + True + True + HealthModel.resx + @@ -267,6 +272,13 @@ PublicResXFileCodeGenerator Reconnect.Designer.cs + + Resx + EmbeddedResource + Designer + PublicResXFileCodeGenerator + HealthModel.Designer.cs + diff --git a/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs b/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs new file mode 100644 index 00000000000..49cb1636a8f --- /dev/null +++ b/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs @@ -0,0 +1,297 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Dashboard.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class HealthModel { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal HealthModel() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Dashboard.Resources.HealthModel", typeof(HealthModel).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Dependencies. + /// + public static string HealthModelDependenciesHeader { + get { + return ResourceManager.GetString("HealthModelDependenciesHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Health states roll up from resources to the logical components that depend on them.. + /// + public static string HealthModelDescription { + get { + return ResourceManager.GetString("HealthModelDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Details. + /// + public static string HealthModelDetailsColumnHeader { + get { + return ResourceManager.GetString("HealthModelDetailsColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Entity. + /// + public static string HealthModelEntityColumnHeader { + get { + return ResourceManager.GetString("HealthModelEntityColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Health model. + /// + public static string HealthModelHeader { + get { + return ResourceManager.GetString("HealthModelHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Health. + /// + public static string HealthModelHealthColumnHeader { + get { + return ResourceManager.GetString("HealthModelHealthColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This entity has no dependencies.. + /// + public static string HealthModelNoDependencies { + get { + return ResourceManager.GetString("HealthModelNoDependencies", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No entities in the health model.. + /// + public static string HealthModelNoEntities { + get { + return ResourceManager.GetString("HealthModelNoEntities", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This entity has no signals of its own. Its health comes entirely from its dependencies.. + /// + public static string HealthModelNoSignals { + get { + return ResourceManager.GetString("HealthModelNoSignals", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} health model. + /// + public static string HealthModelPageTitle { + get { + return ResourceManager.GetString("HealthModelPageTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to From dependencies. + /// + public static string HealthModelPropertyDependenciesState { + get { + return ResourceManager.GetString("HealthModelPropertyDependenciesState", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Impact. + /// + public static string HealthModelPropertyImpact { + get { + return ResourceManager.GetString("HealthModelPropertyImpact", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resource. + /// + public static string HealthModelPropertyResource { + get { + return ResourceManager.GetString("HealthModelPropertyResource", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dependency rollup. + /// + public static string HealthModelPropertyRollup { + get { + return ResourceManager.GetString("HealthModelPropertyRollup", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to From signals. + /// + public static string HealthModelPropertySignalsState { + get { + return ResourceManager.GetString("HealthModelPropertySignalsState", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rollup. + /// + public static string HealthModelRollupColumnHeader { + get { + return ResourceManager.GetString("HealthModelRollupColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At most {0} not healthy. + /// + public static string HealthModelRollupMaxNotHealthy { + get { + return ResourceManager.GetString("HealthModelRollupMaxNotHealthy", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to At least {0} healthy. + /// + public static string HealthModelRollupMinHealthy { + get { + return ResourceManager.GetString("HealthModelRollupMinHealthy", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Worst of. + /// + public static string HealthModelRollupWorstOf { + get { + return ResourceManager.GetString("HealthModelRollupWorstOf", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Signal. + /// + public static string HealthModelSignalColumnHeader { + get { + return ResourceManager.GetString("HealthModelSignalColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} of {1} healthy. + /// + public static string HealthModelSignalCount { + get { + return ResourceManager.GetString("HealthModelSignalCount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Signals. + /// + public static string HealthModelSignalsColumnHeader { + get { + return ResourceManager.GetString("HealthModelSignalsColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Signals. + /// + public static string HealthModelSignalsHeader { + get { + return ResourceManager.GetString("HealthModelSignalsHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to State. + /// + public static string HealthModelStateColumnHeader { + get { + return ResourceManager.GetString("HealthModelStateColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Type. + /// + public static string HealthModelTypeColumnHeader { + get { + return ResourceManager.GetString("HealthModelTypeColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View resource. + /// + public static string HealthModelViewResource { + get { + return ResourceManager.GetString("HealthModelViewResource", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Dashboard/Resources/HealthModel.resx b/src/Aspire.Dashboard/Resources/HealthModel.resx new file mode 100644 index 00000000000..895411cdcd9 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/HealthModel.resx @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} health model + {0} is an application name + + + Health model + + + Health states roll up from resources to the logical components that depend on them. + + + Entity + + + Type + + + Health + + + Signals + + + Rollup + + + No entities in the health model. + + + Signals + + + Dependencies + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + This entity has no dependencies. + + + Signal + + + State + + + Details + + + Impact + + + Dependency rollup + + + From signals + + + From dependencies + + + Resource + + + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Worst of + + + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + View resource + + diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf new file mode 100644 index 00000000000..2b238d4a71b --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf new file mode 100644 index 00000000000..30221b257a5 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf new file mode 100644 index 00000000000..86d1cdc3839 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf new file mode 100644 index 00000000000..76a786fb427 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf new file mode 100644 index 00000000000..8b9a5d9b256 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf new file mode 100644 index 00000000000..db353b5f9b8 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf new file mode 100644 index 00000000000..5bfa7908e59 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf new file mode 100644 index 00000000000..3a275dd33b0 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf new file mode 100644 index 00000000000..7caa3a7a00a --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf new file mode 100644 index 00000000000..379bf674815 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf new file mode 100644 index 00000000000..d817cb0a7ea --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf new file mode 100644 index 00000000000..0b659cd42a8 --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf new file mode 100644 index 00000000000..8c35832b32b --- /dev/null +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf @@ -0,0 +1,137 @@ + + + + + + Dependencies + Dependencies + + + + Health states roll up from resources to the logical components that depend on them. + Health states roll up from resources to the logical components that depend on them. + + + + Details + Details + + + + Entity + Entity + + + + Health model + Health model + + + + Health + Health + + + + This entity has no dependencies. + This entity has no dependencies. + + + + No entities in the health model. + No entities in the health model. + + + + This entity has no signals of its own. Its health comes entirely from its dependencies. + This entity has no signals of its own. Its health comes entirely from its dependencies. + + + + {0} health model + {0} health model + {0} is an application name + + + From dependencies + From dependencies + + + + Impact + Impact + + + + Resource + Resource + + + + Dependency rollup + Dependency rollup + + + + From signals + From signals + + + + Rollup + Rollup + + + + At most {0} not healthy + At most {0} not healthy + {0} is a count or percentage of child entities that may be unhealthy + + + At least {0} healthy + At least {0} healthy + {0} is a count or percentage of child entities that must be healthy + + + Worst of + Worst of + + + + Signal + Signal + + + + {0} of {1} healthy + {0} of {1} healthy + {0} is the number of healthy signals, {1} is the total number of signals + + + Signals + Signals + + + + Signals + Signals + + + + State + State + + + + Type + Type + + + + View resource + View resource + + + + + \ No newline at end of file diff --git a/src/Shared/DashboardUrls.cs b/src/Shared/DashboardUrls.cs index e5d66d67480..119daaaa924 100644 --- a/src/Shared/DashboardUrls.cs +++ b/src/Shared/DashboardUrls.cs @@ -15,6 +15,7 @@ internal static class DashboardUrls public const string TracesBasePath = "traces"; public const string LoginBasePath = "login"; public const string HealthBasePath = "health"; + public const string HealthModelBasePath = "healthmodel"; public static string ResourcesUrl(string? resource = null, string? view = null, string? hiddenTypes = null, string? hiddenStates = null, string? hiddenHealthStates = null) { @@ -149,6 +150,17 @@ public static string TraceDetailUrl(string traceId, string? spanId = null) return url; } + public static string HealthModelUrl(string? entity = null) + { + var url = $"/{HealthModelBasePath}"; + if (entity != null) + { + url = AddQueryString(url, "entity", entity); + } + + return url; + } + public static string LoginUrl(string? returnUrl = null, string? token = null) { var url = $"/{LoginBasePath}"; From 556371d92f5463f28a4e097d8bd4b7f7a9c33c75 Mon Sep 17 00:00:00 2001 From: James Gould Date: Tue, 11 Aug 2026 15:58:03 +0100 Subject: [PATCH 14/28] Add health state icon helper --- .../HealthModel/HealthModelIconHelpers.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelIconHelpers.cs diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelIconHelpers.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelIconHelpers.cs new file mode 100644 index 00000000000..65583a6b89a --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelIconHelpers.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.FluentUI.AspNetCore.Components; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; + +namespace Aspire.Dashboard.Model.HealthModel; + +/// +/// Maps health model states to the icons and colors used to render them. +/// +/// +/// Deliberately reuses the same icon and color pairs as +/// so a resource shows the same visual state on the resources page and in the health model. +/// +public static class HealthModelIconHelpers +{ + /// Gets the icon and color used to represent a health state. + public static (Icon Icon, Color Color) GetHealthStateIcon(HealthState state) => state switch + { + HealthState.Healthy => (new Icons.Filled.Size16.Heart(), Color.Success), + HealthState.Degraded => (new Icons.Filled.Size16.HeartBroken(), Color.Warning), + HealthState.Unhealthy => (new Icons.Filled.Size16.HeartBroken(), Color.Error), + _ => (new Icons.Regular.Size16.CircleHint(), Color.Info) + }; +} From 2c988650dbe493a37a9e0d8cc8ff7a58ce62b1c2 Mon Sep 17 00:00:00 2001 From: James Gould Date: Wed, 12 Aug 2026 17:35:09 +0100 Subject: [PATCH 15/28] Add health model page and entity details pane --- .../Controls/HealthModelEntityDetails.razor | 144 ++++++++++++++++ .../HealthModelEntityDetails.razor.css | 37 ++++ .../Components/Pages/HealthModel.razor | 117 +++++++++++++ .../Components/Pages/HealthModel.razor.cs | 160 ++++++++++++++++++ .../Components/Pages/HealthModel.razor.css | 42 +++++ 5 files changed, 500 insertions(+) create mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor create mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css create mode 100644 src/Aspire.Dashboard/Components/Pages/HealthModel.razor create mode 100644 src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor new file mode 100644 index 00000000000..5f2e8234251 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor @@ -0,0 +1,144 @@ +@using Aspire.Dashboard.Components.Controls.Grid +@using Aspire.Dashboard.Model.HealthModel +@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils + +@inject IStringLocalizer Loc + +
+ + +
+
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelTypeColumnHeader)] + @Node.Entity.Category +
+
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelHealthColumnHeader)] + + @{ + var (icon, color) = HealthModelIconHelpers.GetHealthStateIcon(Node.State); + } + + @Node.State.ToString() + +
+
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertySignalsState)] + @Node.SignalsState.ToString() +
+
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyDependenciesState)] + + @if (Node.DependenciesState is { } dependenciesState) + { + @dependenciesState.ToString() + } + else + { + + } + +
+
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyImpact)] + @Node.Entity.Impact.ToString() +
+
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyRollup)] + @RollupDescription +
+ @if (Node.Entity.ResourceName is { } resourceName) + { +
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyResource)] + + @resourceName + +
+ } +
+
+ + +
+ @Node.Entity.Signals.Length +
+ @if (Node.Entity.Signals.Length == 0) + { +
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelNoSignals)]
+ } + else + { + + + @(context.DisplayName ?? context.Name) + + + @{ + var (signalIcon, signalColor) = HealthModelIconHelpers.GetHealthStateIcon(context.State); + } + + @context.State.ToString() + + + @context.Description + + + } +
+ + +
+ @Node.Children.Length +
+ @if (Node.Children.Length == 0) + { +
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelNoDependencies)]
+ } + else + { + + + @context.DisplayName + + + @{ + var (childIcon, childColor) = HealthModelIconHelpers.GetHealthStateIcon(context.State); + } + + @context.State.ToString() + + + @context.Entity.Impact.ToString() + + + } +
+
+
+ +@code { + [Parameter, EditorRequired] + public required HealthModelNode Node { get; set; } + + private string RollupDescription => Pages.HealthModel.GetRollupDescription(Node.Entity.Dependencies, Loc); +} diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css new file mode 100644 index 00000000000..8b733654c12 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css @@ -0,0 +1,37 @@ +.health-model-details-layout { + height: 100%; + overflow: auto; +} + +::deep .health-model-property-list { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +::deep .health-model-property { + display: grid; + grid-template-columns: 1fr 1.5fr; + gap: 8px; + align-items: center; +} + +::deep .health-model-property-name { + color: var(--neutral-foreground-hint); +} + +::deep .health-model-property-value { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +::deep .health-model-empty { + color: var(--neutral-foreground-hint); + padding: 8px 0; +} diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor new file mode 100644 index 00000000000..78c5fe8d784 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor @@ -0,0 +1,117 @@ +@page "/healthmodel" + +@using Aspire.Dashboard.Components.Controls.Grid +@using Aspire.Dashboard.Model.HealthModel +@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils + +@inject IStringLocalizer Loc +@inject IStringLocalizer ControlsStringsLoc + +@implements IAsyncDisposable + + + + + +
+ + +

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelHeader)]

+
+ + + + +
+
+ @{ + var (overallIcon, overallColor) = HealthModelIconHelpers.GetHealthStateIcon(_snapshot.State); + } + + @_snapshot.State.ToString() + + @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelDescription)] + +
+ +
+ + + + @{ + var (icon, color) = HealthModelIconHelpers.GetHealthStateIcon(context.State); + } + + + @context.DisplayName + + + + + @context.Entity.Category + + + + @context.State.ToString() + + + + @if (context.Entity.Signals.Length > 0) + { + @GetSignalSummary(context) + } + else + { + + } + + + + @if (context.Children.Length > 0) + { + @GetRollupDescription(context) + } + else + { + + } + + + +  @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelNoEntities)] + + +
+
+
+
+ +
+
+
+
+
diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs new file mode 100644 index 00000000000..7606d44aacb --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs @@ -0,0 +1,160 @@ +// 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.Model; +using Aspire.Dashboard.Model.HealthModel; +using Aspire.Dashboard.Utils; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Pages; + +public partial class HealthModel : ComponentBase, IAsyncDisposable +{ + /// The left padding, in pixels, applied per level of model depth in the entity column. + private const int IndentPerDepth = 16; + + private readonly CancellationTokenSource _cts = new(); + private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); + + private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default; + private ColumnSortLabels _sortLabels = ColumnSortLabels.Default; + private HealthModelSnapshot _snapshot = HealthModelSnapshot.Empty; + private HealthModelNode? _selectedNode; + private Task? _resourceSubscriptionTask; + + [Inject] + public required DashboardDataSource DataSource { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Parameter] + [SupplyParameterFromQuery(Name = "entity")] + public string? EntityName { get; set; } + + protected override void OnInitialized() + { + (_resizeLabels, _sortLabels) = DashboardUIHelpers.CreateGridLabels(ControlsStringsLoc); + } + + protected override async Task OnInitializedAsync() + { + var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_cts.Token); + + foreach (var resource in snapshot) + { + _resourceByName[resource.Name] = resource; + } + + RebuildModel(); + + _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(() => + { + RebuildModel(); + StateHasChanged(); + }); + } + }); + } + + protected override void OnParametersSet() + { + // The selected entity is carried in the query string so the details pane survives a page reload. + if (EntityName is not null && _selectedNode?.Name != EntityName) + { + _selectedNode = _snapshot.AllNodes.FirstOrDefault(n => string.Equals(n.Name, EntityName, StringComparison.Ordinal)); + } + } + + private void RebuildModel() + { + var definition = AspireHealthModelBuilder.Build(_resourceByName.Values); + _snapshot = HealthModelEvaluator.Evaluate(definition); + + // Entities are rebuilt from scratch on every resource change, so the previously selected node is a + // stale instance. Re-resolve it by name to keep the details pane pointing at live data. + if (_selectedNode is not null) + { + _selectedNode = _snapshot.AllNodes.FirstOrDefault(n => string.Equals(n.Name, _selectedNode.Name, StringComparison.Ordinal)); + } + } + + private void SelectEntity(HealthModelNode node) + { + _selectedNode = node; + NavigationManager.NavigateTo(DashboardUrls.HealthModelUrl(node.Name), replace: true); + } + + private void ClearSelectedEntity() + { + _selectedNode = null; + NavigationManager.NavigateTo(DashboardUrls.HealthModelUrl(), replace: true); + } + + private static string GetIndentStyle(HealthModelNode node) + => $"padding-left: {node.Depth * IndentPerDepth}px;"; + + private string GetSignalSummary(HealthModelNode node) + { + var healthy = node.Entity.Signals.Count(s => s.State == HealthState.Healthy); + + return string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(Dashboard.Resources.HealthModel.HealthModelSignalCount)], + healthy, + node.Entity.Signals.Length); + } + + private string GetRollupDescription(HealthModelNode node) => GetRollupDescription(node.Entity.Dependencies, Loc); + + /// + /// Describes a dependency rollup in the terms the Azure portal uses, so the configured aggregation is + /// readable without having to know the enum values. + /// + internal static string GetRollupDescription(DependenciesAggregation aggregation, IStringLocalizer loc) + { + if (aggregation.AggregationType == DependenciesAggregationType.WorstOf) + { + return loc[nameof(Dashboard.Resources.HealthModel.HealthModelRollupWorstOf)]; + } + + var threshold = aggregation.UnhealthyThreshold ?? aggregation.DegradedThreshold ?? 0; + var formattedThreshold = aggregation.Unit == AggregationUnit.Percentage + ? threshold.ToString("0.##", CultureInfo.CurrentCulture) + "%" + : threshold.ToString("0.##", CultureInfo.CurrentCulture); + + var format = aggregation.AggregationType == DependenciesAggregationType.MinHealthy + ? loc[nameof(Dashboard.Resources.HealthModel.HealthModelRollupMinHealthy)] + : loc[nameof(Dashboard.Resources.HealthModel.HealthModelRollupMaxNotHealthy)]; + + return string.Format(CultureInfo.CurrentCulture, format, formattedThreshold); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _cts.Dispose(); + + await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask); + } +} diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css new file mode 100644 index 00000000000..d118fb84224 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css @@ -0,0 +1,42 @@ +::deep table.main-grid { + margin-bottom: 1px !important; /* make bottom table row border visible when scrolling */ +} + +::deep .health-model-summary-layout { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + width: 100%; + grid-template-areas: + "overview" + "main"; +} + +::deep .health-model-overview { + grid-area: overview; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); +} + +::deep .health-model-overview-state { + font-weight: 600; +} + +::deep .health-model-overview-description { + color: var(--neutral-foreground-hint); +} + +::deep .health-model-grid-container { + grid-area: main; + overflow: auto; +} + +::deep .health-model-entity-name { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} From fedf651350322a9826bedfb2c907262e1211ab36 Mon Sep 17 00:00:00 2001 From: James Gould Date: Thu, 13 Aug 2026 09:48:51 +0100 Subject: [PATCH 16/28] Add health model to desktop and mobile navigation --- .../Components/Layout/DesktopNavMenu.razor | 8 ++++++++ .../Components/Layout/DesktopNavMenu.razor.cs | 4 ++++ .../Components/Layout/MobileNavMenu.razor.cs | 11 +++++++++++ src/Aspire.Dashboard/Resources/Layout.Designer.cs | 9 +++++++++ src/Aspire.Dashboard/Resources/Layout.resx | 3 +++ src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf | 5 +++++ 18 files changed, 100 insertions(+) diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor index 7ef2b7a5119..5011947fe1b 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor +++ b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor @@ -30,4 +30,12 @@ IconRest="MetricsIcon()" IconActive="MetricsIcon(active: true)" Text="@Loc[nameof(Layout.NavMenuMetricsTab)]" /> + @if (DashboardClient.IsEnabled) + { + + } diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs index e5cb85a3c19..7d19511a18b 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs @@ -31,6 +31,10 @@ internal static Icon MetricsIcon(bool active = false) => active ? new Icons.Filled.Size24.ChartMultiple() : new Icons.Regular.Size24.ChartMultiple(); + internal static Icon HealthModelIcon(bool active = false) => + active ? new Icons.Filled.Size24.Heart() + : new Icons.Regular.Size24.Heart(); + [Inject] public required NavigationManager NavigationManager { get; init; } diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs index ba8a3f0d0fb..6e2c41c9b4e 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs @@ -160,6 +160,17 @@ private IEnumerable GetMobileNavMenuEntries() LinkMatchRegex: GetNonIndexPageRegex(DashboardUrls.MetricsUrl()) ); + if (DashboardClient.IsEnabled) + { + yield return new MobileNavMenuEntry( + Loc[nameof(Resources.Layout.NavMenuHealthModelTab)], + () => NavigateToAsync(DashboardUrls.HealthModelUrl()), + DesktopNavMenu.HealthModelIcon(), + ActiveIcon: DesktopNavMenu.HealthModelIcon(active: true), + LinkMatchRegex: GetNonIndexPageRegex(DashboardUrls.HealthModelUrl()) + ); + } + yield return new MobileNavMenuEntry( Loc[nameof(Resources.Layout.MainLayoutAspireRepoLink)], async () => diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 85c7d8f71bc..ee30d2c1174 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -240,6 +240,15 @@ public static string NavMenuConsoleLogsTab { } } + /// + /// Looks up a localized string similar to Health. + /// + public static string NavMenuHealthModelTab { + get { + return ResourceManager.GetString("NavMenuHealthModelTab", resourceCulture); + } + } + /// /// Looks up a localized string similar to Metrics. /// diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 71fdada3c83..8e531496932 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -159,6 +159,9 @@ Metrics + + Health + Expand navigation labels diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index f37aeabb244..b8802e4e764 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -107,6 +107,11 @@ Konzola + + Health + Health + + Metrics Metriky diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 65371fef389..6c241de9f69 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -107,6 +107,11 @@ Konsole + + Health + Health + + Metrics Metriken diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index f350e5f2433..9252582a591 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -107,6 +107,11 @@ Consola + + Health + Health + + Metrics Métricas diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 285623dd25d..5d88e651bc9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -107,6 +107,11 @@ Console + + Health + Health + + Metrics Métriques diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index cfaea90e6cd..1f1fa6301c1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -107,6 +107,11 @@ Console + + Health + Health + + Metrics Metriche diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index b5211f6200e..10fb1faf894 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -107,6 +107,11 @@ コンソール + + Health + Health + + Metrics メトリック diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 169f1767a2d..b64500b302b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -107,6 +107,11 @@ 콘솔 + + Health + Health + + Metrics 메트릭 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 5d55c300631..272584a6efb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -107,6 +107,11 @@ Konsola + + Health + Health + + Metrics Metryki diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index 267f4267194..33792a6df90 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -107,6 +107,11 @@ Console + + Health + Health + + Metrics Métricas diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index f6bd0827c12..0694e385258 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -107,6 +107,11 @@ Консоль + + Health + Health + + Metrics Метрики diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 7d3730a8fd3..4eaedeb527f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -107,6 +107,11 @@ Konsol + + Health + Health + + Metrics Ölçümler diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index ae54b45ad84..22216bde9e3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -107,6 +107,11 @@ 控制台 + + Health + Health + + Metrics 指标 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 864d2318264..5cb0d0f7bb8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -107,6 +107,11 @@ 主控台 + + Health + Health + + Metrics 計量 From 2abf5d8319febda7ddb8f80ac9265bffa34a1854 Mon Sep 17 00:00:00 2001 From: James Gould Date: Thu, 13 Aug 2026 16:14:27 +0100 Subject: [PATCH 17/28] Add page render tests and fix the data grid queryable crash --- .../Controls/HealthModelEntityDetails.razor | 15 ++- .../Components/Pages/HealthModel.razor | 2 +- .../Components/Pages/HealthModel.razor.cs | 8 ++ .../Pages/HealthModelTests.cs | 122 ++++++++++++++++++ 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor index 5f2e8234251..62984dd03c1 100644 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor @@ -71,7 +71,7 @@ else { _signals = Enumerable.Empty().AsQueryable(); + private IQueryable _children = Enumerable.Empty().AsQueryable(); + private string RollupDescription => Pages.HealthModel.GetRollupDescription(Node.Entity.Dependencies, Loc); + + protected override void OnParametersSet() + { + _signals = Node.Entity.Signals.ToList().AsQueryable(); + _children = Node.Children.ToList().AsQueryable(); + } } diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor index 78c5fe8d784..b258bcdbe69 100644 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor @@ -41,7 +41,7 @@
is a struct, so a queryable built directly over one produces an expression tree + // typed as ImmutableArray that those operators reject at runtime. Materializing into a list first + // keeps the expression typed as a reference type, and caching it avoids rebuilding on every render. + private IQueryable _nodes = Enumerable.Empty().AsQueryable(); + private HealthModelNode? _selectedNode; private Task? _resourceSubscriptionTask; @@ -90,6 +97,7 @@ private void RebuildModel() { var definition = AspireHealthModelBuilder.Build(_resourceByName.Values); _snapshot = HealthModelEvaluator.Evaluate(definition); + _nodes = _snapshot.AllNodes.ToList().AsQueryable(); // Entities are rebuilt from scratch on every resource change, so the previously selected node is a // stale instance. Re-resolve it by name to keep the details pane pointing at live data. diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs new file mode 100644 index 00000000000..9f60d6b3f58 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.HealthModel; +using Aspire.Dashboard.Tests.Shared; +using Aspire.Dashboard.Utils; +using Aspire.Tests.Shared.DashboardModel; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Pages; + +[UseCulture("en-US")] +public class HealthModelTests : DashboardTestContext +{ + [Fact] + public void Render_ProjectsAndContainers_ShowsEntityHierarchy() + { + var cut = RenderHealthModelPage( + ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running), + ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running)); + + cut.WaitForAssertion(() => + { + var text = cut.Markup; + Assert.Contains("Application", text, StringComparison.Ordinal); + Assert.Contains("Services", text, StringComparison.Ordinal); + Assert.Contains("Infrastructure", text, StringComparison.Ordinal); + Assert.Contains("api", text, StringComparison.Ordinal); + Assert.Contains("cache", text, StringComparison.Ordinal); + }); + } + + [Fact] + public void Render_AllResourcesRunning_ShowsHealthyOverallState() + { + var cut = RenderHealthModelPage( + ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running)); + + cut.WaitForAssertion(() => + { + var overview = cut.Find(".health-model-overview-state"); + Assert.Equal(nameof(HealthState.Healthy), overview.TextContent.Trim()); + }); + } + + [Fact] + public void Render_NoResources_StillShowsLogicalEntities() + { + var cut = RenderHealthModelPage(); + + cut.WaitForAssertion(() => + { + var overview = cut.Find(".health-model-overview-state"); + Assert.Equal(nameof(HealthState.Unknown), overview.TextContent.Trim()); + Assert.Contains("Services", cut.Markup, StringComparison.Ordinal); + }); + } + + [Fact] + public void Render_SelectedEntity_ShowsSignalsInDetailsPane() + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + initialResources: + [ + ModelTestHelpers.CreateResource( + resourceName: "api", + displayName: "api", + resourceType: KnownResourceTypes.Project, + state: KnownResourceState.Running, + healthReports: [new HealthReportViewModel("live", HealthStatus.Degraded, "Warming up", null)]) + ], + resourceChannelProvider: Channel.CreateUnbounded>); + + ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); + + // The selected entity is a query string parameter, so navigate to the deep link rather than + // supplying the parameter directly. This also exercises the real deep-link path. + var navigationManager = Services.GetRequiredService(); + navigationManager.NavigateTo(DashboardUrls.HealthModelUrl("api_0")); + + var cut = RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + + cut.WaitForAssertion(() => + { + var details = cut.FindComponent(); + var markup = details.Markup; + + Assert.Contains("Resource state", markup, StringComparison.Ordinal); + Assert.Contains("live", markup, StringComparison.Ordinal); + Assert.Contains("Warming up", markup, StringComparison.Ordinal); + }); + } + + private IRenderedComponent RenderHealthModelPage(params ResourceViewModel[] resources) + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + initialResources: resources, + resourceChannelProvider: Channel.CreateUnbounded>); + + ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); + + return RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + } +} From 3d23760a85a9dbe4823054b793bf040f4d307bd7 Mon Sep 17 00:00:00 2001 From: James Gould Date: Fri, 14 Aug 2026 10:11:05 +0100 Subject: [PATCH 18/28] Surface a single unhealthy container on the application entity --- .../HealthModel/AspireHealthModelBuilder.cs | 17 +++++++++-------- .../Pages/HealthModelTests.cs | 14 ++++++++++++++ .../Model/AspireHealthModelBuilderTests.cs | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs index d02d258084c..bc6c648938f 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs @@ -17,12 +17,12 @@ namespace Aspire.Dashboard.Model.HealthModel; /// /// aspire-app-health (root, worst-of rollup) /// |- services (projects and executables, standard impact) -/// |- infrastructure (containers, limited impact, tolerates one unhealthy member) +/// |- infrastructure (containers, limited impact, threshold rollup) /// /// -/// The two logical entities exist to exercise the parts of the Azure model that are not obvious: the root -/// uses a plain worst-of rollup, while infrastructure combines a threshold rollup with limited impact -/// so a single broken container degrades the application rather than failing it outright. +/// The two logical entities exist to exercise the parts of the Azure model that are not obvious: a broken +/// service fails the application outright, while a broken container makes the infrastructure group unhealthy +/// and limited impact rewrites that to degraded by the time it reaches the application. /// /// public static class AspireHealthModelBuilder @@ -76,13 +76,14 @@ public static HealthModelDefinition Build(IEnumerable resourc // reported to the application as degraded rather than unhealthy. Impact = EntityImpact.Limited, - // Tolerate a single unhealthy container before the group itself reports a problem. This is the - // "4 VMs, tolerate 1 offline" pattern from the Azure docs expressed as a not-healthy limit. + // Any container that is not healthy makes the group unhealthy. There is deliberately no degraded + // threshold: limited impact swallows a degraded child entirely, so a degraded tier here would be + // invisible at the application level. Going straight to unhealthy means limited impact rewrites + // it to degraded and a single broken container is still surfaced on the application entity. Dependencies = new DependenciesAggregation { AggregationType = DependenciesAggregationType.MaxNotHealthy, - DegradedThreshold = 1, - UnhealthyThreshold = 2, + UnhealthyThreshold = 1, Unit = AggregationUnit.Absolute } }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs index 9f60d6b3f58..766bdf1bb51 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs @@ -51,6 +51,20 @@ public void Render_AllResourcesRunning_ShowsHealthyOverallState() }); } + [Fact] + public void Render_FailedContainer_ShowsDegradedOverallState() + { + var cut = RenderHealthModelPage( + ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running), + ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.FailedToStart)); + + cut.WaitForAssertion(() => + { + var overview = cut.Find(".health-model-overview-state"); + Assert.Equal(nameof(HealthState.Degraded), overview.TextContent.Trim()); + }); + } + [Fact] public void Render_NoResources_StillShowsLogicalEntities() { diff --git a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs index 814bed6601d..fb688716738 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs @@ -171,6 +171,22 @@ public void MapHealthStatus_MapsHealthCheckResults(HealthStatus status, HealthSt Assert.Equal(expected, AspireHealthModelBuilder.MapHealthStatus(status)); } + [Fact] + public void BuildAndEvaluate_UnhealthyContainer_DegradesApplicationRatherThanFailingIt() + { + // The container group itself reports unhealthy, but infrastructure has limited impact so the + // application only sees degraded. This is the behaviour the sample model exists to demonstrate. + var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); + var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.FailedToStart); + + var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project, container])); + + Assert.Equal(HealthState.Degraded, snapshot.State); + + var infrastructure = Assert.Single(snapshot.AllNodes, n => n.Name == AspireHealthModelBuilder.InfrastructureEntityName); + Assert.Equal(HealthState.Unhealthy, infrastructure.State); + } + [Fact] public void BuildAndEvaluate_UnhealthyProject_FailsApplication() { From 17d043ef31951d7ec989c58b7f909c5dd7a65957 Mon Sep 17 00:00:00 2001 From: James Gould Date: Fri, 14 Aug 2026 15:33:48 +0100 Subject: [PATCH 19/28] Fix subscription disposal ordering and back navigation --- .../Components/Pages/HealthModel.razor.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs index d3d4bd8e5a3..146f177c49f 100644 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs @@ -86,8 +86,14 @@ await InvokeAsync(() => protected override void OnParametersSet() { - // The selected entity is carried in the query string so the details pane survives a page reload. - if (EntityName is not null && _selectedNode?.Name != EntityName) + // The selected entity is carried in the query string so the details pane survives a page reload and + // follows browser navigation. Clearing when the parameter is absent is what closes the pane when the + // user navigates back to the page without a selection. + if (EntityName is null) + { + _selectedNode = null; + } + else if (_selectedNode?.Name != EntityName) { _selectedNode = _snapshot.AllNodes.FirstOrDefault(n => string.Equals(n.Name, EntityName, StringComparison.Ordinal)); } @@ -161,8 +167,11 @@ internal static string GetRollupDescription(DependenciesAggregation aggregation, public async ValueTask DisposeAsync() { await _cts.CancelAsync(); - _cts.Dispose(); + // Wait for the subscription loop to unwind before disposing the source. Disposing it first would + // make the loop throw ObjectDisposedException while observing the token. await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask); + + _cts.Dispose(); } } From ab38c2555a1435523e24473c2724b00ac3192e62 Mon Sep 17 00:00:00 2001 From: James Gould Date: Fri, 14 Aug 2026 18:07:12 +0100 Subject: [PATCH 20/28] Document the Bicep translation path --- .../Model/HealthModel/HealthModelEntity.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs index 34af6155285..5948414c3a4 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs @@ -70,6 +70,24 @@ public sealed record HealthModelRelationship(string ParentEntityName, string Chi /// /// A complete health model: a set of entities and the relationships that connect them. /// +/// +/// +/// The shape of this type mirrors Microsoft.CloudHealth/healthmodels and its entities and +/// relationships child resources so the model can be translated to Bicep. and +/// are kept as flat lists rather than a tree for that reason: the Azure model is +/// a graph in which an entity may have several parents, and relationships are standalone resources. +/// +/// +/// Two pieces are still required before a model can be deployed, and neither can be derived from the local +/// app model: an entity that represents a real Azure resource needs the ARM resource ID of its deployed +/// counterpart, and every data-source signal group needs an authenticationsettings resource to read +/// through. Both arrive with deployment information rather than from the running app host. +/// +/// +/// Target the 2026-05-01-preview API version, which is the newest version with generated Bicep types. +/// See https://learn.microsoft.com/azure/azure-monitor/health-models/tutorial-bicep. +/// +/// public sealed record HealthModelDefinition { /// From f094f75dd8d2e42fb9579fdf7189c2fd7c9ba2fe Mon Sep 17 00:00:00 2001 From: James Gould Date: Mon, 7 Sep 2026 19:54:48 +0100 Subject: [PATCH 21/28] entity relationship is now expressed, but poor --- .../Components/Pages/Resources.razor.cs | 2 +- .../Components/Pages/Resources.razor.css | 76 ++++++++++++ .../Model/ResourceGraph/ResourceDto.cs | 15 ++- .../ResourceGraph/ResourceGraphMapper.cs | 59 ++++++--- .../wwwroot/js/app-resourcegraph.js | 117 +++++++++++++++--- .../Model/ResourceGraphMapperTests.cs | 44 +++++-- 6 files changed, 268 insertions(+), 45 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 4c2aa498f4c..bdebb900509 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -417,7 +417,7 @@ private async Task UpdateResourceGraphResourcesAsync() } var activeResources = _resourceByName.Values.Where(Filter).OrderBy(e => e.ResourceType).ThenBy(e => e.Name).ToList(); - var resources = activeResources.Select(r => ResourceGraphMapper.MapResource(r, activeResources, _resourceByName, ColumnsLoc, PageViewModel.ShowHiddenResources, IconResolver)).ToList(); + var resources = ResourceGraphMapper.MapResources(activeResources, _resourceByName, ColumnsLoc, PageViewModel.ShowHiddenResources, IconResolver); await _jsModule.InvokeVoidAsync("updateResourcesGraph", resources); } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css index 2d763b42fea..99ea855bc8d 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css @@ -292,18 +292,43 @@ marker-end: url(#arrow-normal); } +/* Links are coloured by the rolled up health of the child they point at, so an unhealthy resource tints + every link on the path back to the root. Unknown is intentionally not styled: it is the least severe + state, so a resource that hasn't reported yet stays neutral instead of drawing attention. */ +::deep .resource-link[data-health="Healthy"] { + stroke: var(--aspire-status-success); + stroke-width: 2; +} + +::deep .resource-link[data-health="Degraded"] { + stroke: var(--aspire-status-warning); + stroke-width: 2; +} + +::deep .resource-link[data-health="Unhealthy"] { + stroke: var(--aspire-status-error); + stroke-width: 2; +} + +/* Arrow heads take the colour of the line they terminate so health colouring carries through to them. + The literal fill is a fallback for browsers without context-stroke support. */ ::deep .arrow-normal { fill: var(--neutral-stroke-rest); + fill: context-stroke; } ::deep .arrow-highlight { fill: var(--neutral-stroke-hover); + fill: context-stroke; } ::deep .arrow-highlight-expand { fill: var(--neutral-stroke-hover); + fill: context-stroke; } +/* Health colouring deliberately outranks the neutral highlight stroke so hovering a link never hides the + health signal. The dash pattern and width still communicate the highlight. */ ::deep .resource-link-highlight { stroke: var(--neutral-stroke-hover); stroke-dasharray: 5,5; @@ -318,6 +343,57 @@ marker-end: url(#arrow-highlight-expand); } +::deep .resource-link-highlight[data-health="Healthy"], +::deep .resource-link-highlight-expand[data-health="Healthy"] { + stroke: var(--aspire-status-success); +} + +::deep .resource-link-highlight[data-health="Degraded"], +::deep .resource-link-highlight-expand[data-health="Degraded"] { + stroke: var(--aspire-status-warning); +} + +::deep .resource-link-highlight[data-health="Unhealthy"], +::deep .resource-link-highlight-expand[data-health="Unhealthy"] { + stroke: var(--aspire-status-error); +} + +/* A node carries its own rolled up state: a full strength outline plus a 30% tint of the same colour over + the node surface. The outline has no competing !important rule, so it survives hover and selection and + the health signal is never lost. */ +::deep .resource-group[data-health="Healthy"] .resource-node { + fill: color-mix(in srgb, var(--aspire-status-success) 30%, var(--fill-color)); +} + +::deep .resource-group[data-health="Healthy"] .resource-node-border { + stroke: var(--aspire-status-success); + stroke-width: 2; +} + +::deep .resource-group[data-health="Degraded"] .resource-node { + fill: color-mix(in srgb, var(--aspire-status-warning) 30%, var(--fill-color)); +} + +::deep .resource-group[data-health="Degraded"] .resource-node-border { + stroke: var(--aspire-status-warning); + stroke-width: 2; +} + +::deep .resource-group[data-health="Unhealthy"] .resource-node { + fill: color-mix(in srgb, var(--aspire-status-error) 30%, var(--fill-color)); +} + +::deep .resource-group[data-health="Unhealthy"] .resource-node-border { + stroke: var(--aspire-status-error); + stroke-width: 2; +} + +/* A node the user has dragged is pinned in place. The dashed outline marks it as manually positioned + without competing with the health colour, which owns the stroke colour. */ +::deep .resource-group-pinned .resource-node-border { + stroke-dasharray: 4 3; +} + ::deep .tab-label > svg { margin-right: calc(var(--design-unit) * 1px); } diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs index 2ebb8ce57bc..4e3bacdaf43 100644 --- a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs @@ -15,5 +15,18 @@ public sealed class ResourceDto public required IconDto StateIcon { get; init; } public required string? EndpointUrl { get; init; } public required string? EndpointText { get; init; } - public required ImmutableArray ReferencedNames { get; init; } + + /// + /// The names of the resources this resource depends on. Each becomes a parent-to-child link in the graph. + /// + public required ImmutableArray ChildNames { get; init; } + + /// + /// The health state to display for this resource, after the health of everything it depends on has been + /// rolled up into it. Used to colour the node and the links to its children. + /// + /// + /// Serialized as the enum name because the value is only used by the graph script to pick a CSS class. + /// + public required string HealthState { get; init; } } diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs index ccc40dfce33..074373dd38f 100644 --- a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Xml.Linq; +using Aspire.Dashboard.Model.HealthModel; using Aspire.Dashboard.Resources; using Microsoft.Extensions.Localization; using Microsoft.FluentUI.AspNetCore.Components; @@ -12,26 +13,53 @@ namespace Aspire.Dashboard.Model.ResourceGraph; public static class ResourceGraphMapper { - public static ResourceDto MapResource(ResourceViewModel r, IEnumerable graphResources, IDictionary resourcesByName, IStringLocalizer columnsLoc, bool showHiddenResources, IconResolver iconResolver) + /// + /// Maps every resource in the graph, deriving the parent-child structure from app host dependencies and + /// rolling child health up through it. + /// + /// + /// The rollup needs to see the whole graph, so it is computed once here and shared by each mapped + /// resource rather than being recomputed per resource. + /// + public static List MapResources( + IReadOnlyList graphResources, + IDictionary resourcesByName, + IStringLocalizer columnsLoc, + bool showHiddenResources, + IconResolver iconResolver) { - var resolvedNames = new List(); + ArgumentNullException.ThrowIfNull(graphResources); - // Remove relationships back to the current resource. The graph doesn't display self referential relationships. - var filteredRelationships = r.Relationships.Where(relationship => relationship.ResourceName != r.DisplayName); + var edges = ResourceGraphHealth.BuildEdges(graphResources, showHiddenResources); + var healthStates = ResourceGraphHealth.ComputeEffectiveStates(graphResources, edges); - foreach (var resourceRelationships in filteredRelationships.GroupBy(r => r.ResourceName, StringComparers.ResourceName)) + var childNamesByParent = edges + .GroupBy(e => e.ParentName, StringComparers.ResourceName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ChildName).Distinct(StringComparers.ResourceName).OrderBy(n => n, StringComparers.ResourceName).ToImmutableArray(), + StringComparers.ResourceName); + + var dtos = new List(graphResources.Count); + foreach (var resource in graphResources) { - var matches = graphResources - .Where(r => string.Equals(r.DisplayName, resourceRelationships.Key, StringComparisons.ResourceName)) - .Where(r => !r.IsResourceHidden(showHiddenResources)) - .ToList(); + var childNames = childNamesByParent.TryGetValue(resource.Name, out var children) ? children : []; + var healthState = healthStates.TryGetValue(resource.Name, out var state) ? state : HealthState.Unknown; - foreach (var match in matches) - { - resolvedNames.Add(match.Name); - } + dtos.Add(MapResource(resource, resourcesByName, columnsLoc, iconResolver, childNames, healthState)); } + return dtos; + } + + public static ResourceDto MapResource( + ResourceViewModel r, + IDictionary resourcesByName, + IStringLocalizer columnsLoc, + IconResolver iconResolver, + ImmutableArray childNames, + HealthState healthState) + { var endpoint = ResourceUrlHelpers.GetUrls(r, includeInternalUrls: false, includeNonEndpointUrls: false).FirstOrDefault() ?? ResourceUrlHelpers.GetUrls(r, includeInternalUrls: false, includeNonEndpointUrls: true).FirstOrDefault(); var resolvedEndpointText = r.IsParameter ? null : ResolvedEndpointText(endpoint); @@ -46,7 +74,7 @@ public static ResourceDto MapResource(ResourceViewModel r, IEnumerable n).ToImmutableArray(), + ChildNames = childNames, + HealthState = healthState.ToString(), EndpointUrl = r.IsParameter ? null : endpoint?.Url, EndpointText = resolvedEndpointText }; diff --git a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js index c4604602c41..ac652fd1598 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js @@ -99,9 +99,20 @@ class ResourceGraph { if (dragged) { this.simulation.alphaTarget(0); dragged = false; + + // Keep fx/fy so the node stays where it was dropped instead of springing back to wherever + // the simulation wants it. Double clicking the node releases it again. + event.subject.pinned = true; + this.updateNodePinnedState(); + } + else { + // Mousedown without movement is a click, not a drag, so release the temporary fixing applied + // on start. Pinning here would make every click on a node pin it. + if (!event.subject.pinned) { + event.subject.fx = null; + event.subject.fy = null; + } } - event.subject.fx = null; - event.subject.fy = null; }); var defs = this.svg.append("defs"); @@ -147,6 +158,34 @@ class ResourceGraph { resetZoomAndPan() { this.svg.transition().call(this.zoom.transform, d3.zoomIdentity); + this.unpinAllNodes(); + } + + // Releases every pinned node so the layout is driven by the simulation again. + unpinAllNodes() { + var hasPinnedNodes = false; + for (const node of this.nodes) { + if (node.pinned) { + node.pinned = false; + node.fx = null; + node.fy = null; + hasPinnedNodes = true; + } + } + + if (hasPinnedNodes) { + this.updateNodePinnedState(); + this.simulation.alpha(0.3).restart(); + } + } + + // Reflects the pinned state of each node in the DOM so it can be styled. + updateNodePinnedState() { + if (!this.nodeElements) { + return; + } + + this.nodeElements.classed("resource-group-pinned", n => !!n.pinned); } zoomIn() { @@ -196,11 +235,11 @@ class ResourceGraph { if (!this.iconEqual(r1.resourceIcon, r2.resourceIcon)) { return false; } - if (r1.referencedNames.length !== r2.referencedNames.length) { + if (r1.childNames.length !== r2.childNames.length) { return false; } - for (var i = 0; i < r1.referencedNames.length; i++) { - if (r1.referencedNames[i] !== r2.referencedNames[i]) { + for (var i = 0; i < r1.childNames.length; i++) { + if (r1.childNames[i] !== r2.childNames[i]) { return false; } } @@ -243,14 +282,14 @@ class ResourceGraph { // calculate degree (number of connections) for each resource const degreeMap = new Map(); newResources.forEach(resource => { - degreeMap.set(resource.name, resource.referencedNames.length); + degreeMap.set(resource.name, resource.childNames.length); }); // also count incoming connections newResources.forEach(resource => { - resource.referencedNames.forEach(refName => { - const currentDegree = degreeMap.get(refName) || 0; - degreeMap.set(refName, currentDegree + 1); + resource.childNames.forEach(childName => { + const currentDegree = degreeMap.get(childName) || 0; + degreeMap.set(childName, currentDegree + 1); }); }); @@ -259,7 +298,8 @@ class ResourceGraph { const degree = degreeMap.get(resource.name) || 1; if (existingNode) { - // Update existing node without replacing it + // Spreading the existing node preserves simulation state, including the fx/fy of a node the + // user has pinned by dragging it. updatedNodes.push({ ...existingNode, label: resource.displayName, @@ -267,6 +307,7 @@ class ResourceGraph { endpointText: resource.endpointText, resourceIcon: createIcon(resource.resourceIcon), stateIcon: createIcon(resource.stateIcon), + healthState: resource.healthState, degree: degree }); } else { @@ -278,6 +319,7 @@ class ResourceGraph { endpointText: resource.endpointText, resourceIcon: createIcon(resource.resourceIcon), stateIcon: createIcon(resource.stateIcon), + healthState: resource.healthState, degree: degree }); } @@ -303,18 +345,23 @@ class ResourceGraph { this.updateNodes(newResources); this.links = []; + var healthStateByName = new Map(newResources.map(r => [r.name, r.healthState])); for (var i = 0; i < newResources.length; i++) { var resource = newResources[i]; - var resourceLinks = resource.referencedNames - .filter((referencedName) => { - return newResources.some(r => r.name === referencedName); + var resourceLinks = resource.childNames + .filter((childName) => { + return newResources.some(r => r.name === childName); }) - .map((referencedName, index) => { + .map((childName, index) => { return { - id: `${resource.name}-${referencedName}`, - target: referencedName, + id: `${resource.name}-${childName}`, + target: childName, source: resource.name, + // The link takes the child's rolled up state. Because the child's state already + // includes everything below it, an unhealthy leaf colours every link on the path + // back to the root without any extra propagation here. + healthState: healthStateByName.get(childName), strength: 0.7 }; }); @@ -346,6 +393,7 @@ class ResourceGraph { .append("g") .attr("class", "resource-scale") .on('click', this.selectNode) + .on('dblclick', this.unpinNode) .on('contextmenu', this.nodeContextMenu) .on('mouseover', this.hoverNode) .on('mouseout', this.unHoverNode); @@ -476,6 +524,9 @@ class ResourceGraph { this.nodeElements = newNodes.merge(this.nodeElements); // Set resource values that change. + this.nodeElementsG + .selectAll(".resource-group") + .attr("data-health", n => n.healthState); this.nodeElementsG .selectAll(".resource-group") .select(".resource-menu-cog") @@ -527,6 +578,12 @@ class ResourceGraph { this.linkElements = newLinks.merge(this.linkElements); + // Health is refreshed on every update because a resource can change state without the shape of the + // graph changing at all. + this.linkElements.attr("data-health", l => l.healthState); + + this.updateNodePinnedState(); + this.simulation .nodes(this.nodes) .on('tick', this.onTick); @@ -694,6 +751,29 @@ class ResourceGraph { this.updateNodeHighlights(mouseoverNode); } + // Releases a node pinned by dragging so the simulation can lay it out again. + unpinNode = (event) => { + // Child elements keep the datum they were appended with, and updateNodes replaces node objects on + // every refresh, so the datum reachable from the event can be a stale copy. Only the id is stable, + // so the live node is looked up from the simulation's own array. + var id = event.target.__data__?.id; + var node = id ? this.nodes.find(n => n.id === id) : null; + if (!node || !node.pinned) { + return; + } + + // The zoom behavior also handles dblclick. Without this the graph would zoom in while unpinning. + event.preventDefault(); + event.stopPropagation(); + + node.pinned = false; + node.fx = null; + node.fy = null; + + this.updateNodePinnedState(); + this.simulation.alpha(0.3).restart(); + } + unHoverNode = (event) => { // Don't unhover the selected node when the context menu is open. // This is done to keep the node selected until the context menu is closed. @@ -726,6 +806,11 @@ class ResourceGraph { if (neighbors.indexOf(node.id) > -1) { classNames.push('resource-group-highlight'); } + // The class attribute is rebuilt from scratch here, so the pinned marker has to be reapplied + // or dragging a node and then hovering any node would silently unpin it visually. + if (node.pinned) { + classNames.push('resource-group-pinned'); + } return classNames.join(' '); }); this.linkElements.attr('class', (link) => { diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs index b89dff237ee..92b867a14ae 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs @@ -17,6 +17,26 @@ public class ResourceGraphMapperTests { private readonly IconResolver _iconResolver = new IconResolver(NullLogger.Instance); + /// + /// Maps a whole graph and returns the entry for one resource. The parent/child structure and the health + /// rollup are derived across all resources at once, so a single resource can't be mapped in isolation. + /// + private ResourceDto MapSingle( + ResourceViewModel resource, + Dictionary resources, + bool showHiddenResources, + IReadOnlyList? graphResources = null) + { + var dtos = ResourceGraphMapper.MapResources( + graphResources ?? [.. resources.Values], + resources, + new TestStringLocalizer(), + showHiddenResources, + _iconResolver); + + return Assert.Single(dtos, d => d.Name == resource.Name); + } + [Fact] public void MapResource_HasReference_Added() { @@ -30,10 +50,10 @@ public void MapResource_HasReference_Added() }; // Act - var dto = ResourceGraphMapper.MapResource(resource1, resources.Values, resources, new TestStringLocalizer(), showHiddenResources: false, _iconResolver); + var dto = MapSingle(resource1, resources, showHiddenResources: false); // Assert - var referencedName = Assert.Single(dto.ReferencedNames); + var referencedName = Assert.Single(dto.ChildNames); Assert.Equal("app2-123456", referencedName); } @@ -52,10 +72,10 @@ public void MapResource_HasReferenceToReplicas_MultipleAdded() }; // Act - var dto = ResourceGraphMapper.MapResource(resource1, resources.Values, resources, new TestStringLocalizer(), showHiddenResources: false, _iconResolver); + var dto = MapSingle(resource1, resources, showHiddenResources: false); // Assert - Assert.Collection(dto.ReferencedNames, + Assert.Collection(dto.ChildNames, r => Assert.Equal("app2-123456", r), r => Assert.Equal("app2-654321", r)); } @@ -71,10 +91,10 @@ public void MapResource_HasSelfReference_Ignored() }; // Act - var dto = ResourceGraphMapper.MapResource(resource, resources.Values, resources, new TestStringLocalizer(), showHiddenResources: false, _iconResolver); + var dto = MapSingle(resource, resources, showHiddenResources: false); // Assert - Assert.Empty(dto.ReferencedNames); + Assert.Empty(dto.ChildNames); } [Fact] @@ -90,10 +110,10 @@ public void MapResource_ShowHiddenResources_IncludesHiddenResources() }; // Act - var dto = ResourceGraphMapper.MapResource(resource1, resources.Values, resources, new TestStringLocalizer(), showHiddenResources: true, _iconResolver); + var dto = MapSingle(resource1, resources, showHiddenResources: true); // Assert - Assert.Contains("hidden-app", dto.ReferencedNames); + Assert.Contains("hidden-app", dto.ChildNames); } [Fact] @@ -111,7 +131,7 @@ public void MapResource_ParameterResource_NoEndpoint() }; // Act - var dto = ResourceGraphMapper.MapResource(resource, resources.Values, resources, new TestStringLocalizer(), showHiddenResources: false, _iconResolver); + var dto = MapSingle(resource, resources, showHiddenResources: false); // Assert Assert.Null(dto.EndpointUrl); @@ -133,7 +153,7 @@ public void MapResource_NonParameterResource_HasEndpointText() }; // Act - var dto = ResourceGraphMapper.MapResource(resource, resources.Values, resources, new TestStringLocalizer(), showHiddenResources: false, _iconResolver); + var dto = MapSingle(resource, resources, showHiddenResources: false); // Assert - non-parameter resources should always have endpoint text (even if "No endpoints") Assert.NotNull(dto.EndpointText); @@ -154,9 +174,9 @@ public void MapResource_ReferenceToResourceExcludedFromGraph_Ignored() [parameter.Name] = parameter, }; - var dto = ResourceGraphMapper.MapResource(resource, [resource], resources, new TestStringLocalizer(), showHiddenResources: false, _iconResolver); + var dto = MapSingle(resource, resources, showHiddenResources: false, graphResources: [resource]); - Assert.Empty(dto.ReferencedNames); + Assert.Empty(dto.ChildNames); } [Fact] From b86cfb7b3f809dc63f48b4a65b9337c9abfb9a8b Mon Sep 17 00:00:00 2001 From: James Gould Date: Mon, 7 Sep 2026 21:15:45 +0100 Subject: [PATCH 22/28] entity relationship expressed better, connecting health state lines are still kinda wonky though --- .../HealthChecksSandbox.AppHost/AppHost.cs | 40 ++- .../Components/Pages/Resources.razor.cs | 2 +- .../Components/Pages/Resources.razor.css | 29 +- .../ResourceGraph/ResourceGraphMapper.cs | 56 +++- .../Resources/ControlsStrings.Designer.cs | 9 + .../Resources/ControlsStrings.resx | 3 + .../Resources/xlf/ControlsStrings.cs.xlf | 5 + .../Resources/xlf/ControlsStrings.de.xlf | 5 + .../Resources/xlf/ControlsStrings.es.xlf | 5 + .../Resources/xlf/ControlsStrings.fr.xlf | 5 + .../Resources/xlf/ControlsStrings.it.xlf | 5 + .../Resources/xlf/ControlsStrings.ja.xlf | 5 + .../Resources/xlf/ControlsStrings.ko.xlf | 5 + .../Resources/xlf/ControlsStrings.pl.xlf | 5 + .../Resources/xlf/ControlsStrings.pt-BR.xlf | 5 + .../Resources/xlf/ControlsStrings.ru.xlf | 5 + .../Resources/xlf/ControlsStrings.tr.xlf | 5 + .../Resources/xlf/ControlsStrings.zh-Hans.xlf | 5 + .../Resources/xlf/ControlsStrings.zh-Hant.xlf | 5 + .../wwwroot/js/app-resourcegraph.js | 306 ++++++++++++++++-- .../Model/ResourceGraphMapperTests.cs | 87 ++++- 21 files changed, 553 insertions(+), 44 deletions(-) diff --git a/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs b/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs index d40020216b2..980ab09af59 100644 --- a/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs +++ b/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs @@ -11,8 +11,39 @@ builder.Services.TryAddEventingSubscriber(); AddTestResource("healthy", HealthStatus.Healthy, "I'm fine, thanks for asking."); -AddTestResource("unhealthy", HealthStatus.Unhealthy, "I can't do that, Dave.", exceptionMessage: "Feeling unhealthy."); -AddTestResource("degraded", HealthStatus.Degraded, "Had better days.", exceptionMessage: "Feeling degraded."); +var unhealthyResource = AddTestResource("unhealthy", HealthStatus.Unhealthy, "I can't do that, Dave.", exceptionMessage: "Feeling unhealthy."); +var degradedResource = AddTestResource("degraded", HealthStatus.Degraded, "Had better days.", exceptionMessage: "Feeling degraded."); + +// ----------------------------------------------------------------------- +// A small service topology so the resource graph has a hierarchy to draw and health has somewhere to roll +// up to. Every resource here is healthy in its own right, so whatever state they end up showing in the +// graph has been inherited from something they depend on: +// +// storefront ──> checkout-api ──> orders-db ──> orders-db-server (parent/child) +// │ └─> session-cache ──> unhealthy +// └─> catalog-api ──> session-cache +// └─> degraded +// +// storefront therefore ends up unhealthy through the cache, and catalog-api degraded. +// ----------------------------------------------------------------------- +var ordersDbServer = AddTestResource("orders-db-server", HealthStatus.Healthy, "Serving databases."); +var ordersDb = AddTestResource("orders-db", HealthStatus.Healthy, "Accepting queries."); +ordersDb.WithParentRelationship(ordersDbServer); + +var sessionCache = AddTestResource("session-cache", HealthStatus.Healthy, "Cache warm."); +sessionCache.WithReferenceRelationship(unhealthyResource); + +var checkoutApi = AddTestResource("checkout-api", HealthStatus.Healthy, "Taking orders."); +checkoutApi.WithReferenceRelationship(ordersDb) + .WithReferenceRelationship(sessionCache); + +var catalogApi = AddTestResource("catalog-api", HealthStatus.Healthy, "Listing products."); +catalogApi.WithReferenceRelationship(sessionCache) + .WithReferenceRelationship(degradedResource); + +AddTestResource("storefront", HealthStatus.Healthy, "Serving customers.") + .WithReferenceRelationship(checkoutApi) + .WithReferenceRelationship(catalogApi); // ----------------------------------------------------------------------- // External services with HTTP health checks to test friendly error messages @@ -86,7 +117,7 @@ builder.Build().Run(); -void AddTestResource(string name, HealthStatus status, string? description = null, string? exceptionMessage = null) +IResourceBuilder AddTestResource(string name, HealthStatus status, string? description = null, string? exceptionMessage = null) { var hasHealthyAfterFirstRunCheckRun = false; builder.Services.AddHealthChecks() @@ -105,7 +136,7 @@ void AddTestResource(string name, HealthStatus status, string? description = nul return new HealthCheckResult(HealthStatus.Healthy, "Healthy beginning second health check run."); }); - builder + return builder .AddResource(new TestResource(name)) .WithHealthCheck($"{name}_check") .WithHealthCheck($"{name}_resource_healthy_after_first_run_check") @@ -116,7 +147,6 @@ void AddTestResource(string name, HealthStatus status, string? description = nul Properties = [], }) .ExcludeFromManifest(); - return; } internal sealed class TestResource(string name) : Resource(name), IResourceWithEndpoints; diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index bdebb900509..b4b7bd61343 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -417,7 +417,7 @@ private async Task UpdateResourceGraphResourcesAsync() } var activeResources = _resourceByName.Values.Where(Filter).OrderBy(e => e.ResourceType).ThenBy(e => e.Name).ToList(); - var resources = ResourceGraphMapper.MapResources(activeResources, _resourceByName, ColumnsLoc, PageViewModel.ShowHiddenResources, IconResolver); + var resources = ResourceGraphMapper.MapResources(activeResources, _resourceByName, ColumnsLoc, PageViewModel.ShowHiddenResources, IconResolver, DashboardClient.ApplicationName); await _jsModule.InvokeVoidAsync("updateResourcesGraph", resources); } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css index 99ea855bc8d..d2f3df478e3 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css @@ -153,6 +153,31 @@ ::deep .resource-graph-container { grid-area: resources-tab-content; position: relative; /* So graph buttons are position inside the container */ + overflow: hidden; +} + +/* The SVG has to fill its container for the viewBox set in script to map 1:1 to on-screen pixels. + Without this it falls back to the SVG default size and the visible canvas doesn't match the area the + graph is actually laid out in. */ +::deep .resource-graph { + display: block; + width: 100%; + height: 100%; +} + +/* Dot grid drawn under the graph. It lives inside the zoom group so it pans and scales with the content, + which makes the extent of the canvas obvious while dragging nodes around. */ +::deep .resource-graph-grid-dot { + fill: var(--neutral-stroke-rest); + opacity: 0.35; +} + +::deep .resource-graph .nodes { + cursor: grab; +} + +::deep .resource-graph .resource-group-pinned { + cursor: default; } ::deep .resource-graph-controls { @@ -167,10 +192,6 @@ cursor: pointer; } -::deep .resource-graph .nodes { - cursor: pointer; -} - ::deep .resource-name { fill: var(--neutral-foreground-rest); stroke: var(--fill-color); diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs index 074373dd38f..a23d2a717a3 100644 --- a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs @@ -8,25 +8,35 @@ using Microsoft.Extensions.Localization; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; namespace Aspire.Dashboard.Model.ResourceGraph; public static class ResourceGraphMapper { + /// + /// The name of the synthetic entity that roots the graph. Prefixed so it can never collide with a real + /// resource name, which the app host restricts to letters, digits and dashes. + /// + public const string AppHostEntityName = "$apphost"; + /// /// Maps every resource in the graph, deriving the parent-child structure from app host dependencies and /// rolling child health up through it. /// /// /// The rollup needs to see the whole graph, so it is computed once here and shared by each mapped - /// resource rather than being recomputed per resource. + /// resource rather than being recomputed per resource. A synthetic app host entity is prepended so the + /// graph always has a single root to hang the hierarchy from, even when resources declare no + /// relationships at all. /// public static List MapResources( IReadOnlyList graphResources, IDictionary resourcesByName, IStringLocalizer columnsLoc, bool showHiddenResources, - IconResolver iconResolver) + IconResolver iconResolver, + string applicationName) { ArgumentNullException.ThrowIfNull(graphResources); @@ -40,7 +50,7 @@ public static List MapResources( g => g.Select(e => e.ChildName).Distinct(StringComparers.ResourceName).OrderBy(n => n, StringComparers.ResourceName).ToImmutableArray(), StringComparers.ResourceName); - var dtos = new List(graphResources.Count); + var dtos = new List(graphResources.Count + 1); foreach (var resource in graphResources) { var childNames = childNamesByParent.TryGetValue(resource.Name, out var children) ? children : []; @@ -49,9 +59,49 @@ public static List MapResources( dtos.Add(MapResource(resource, resourcesByName, columnsLoc, iconResolver, childNames, healthState)); } + if (graphResources.Count > 0) + { + var rootNames = ResourceGraphHealth.GetRootNames(graphResources, edges); + var appHostState = HealthStateExtensions.WorstOf(rootNames.Select(n => healthStates.TryGetValue(n, out var s) ? s : HealthState.Unknown)); + + dtos.Insert(0, CreateAppHostResource(applicationName, rootNames, appHostState)); + } + return dtos; } + /// + /// Builds the synthetic entity that represents the app host itself and roots the graph. + /// + private static ResourceDto CreateAppHostResource(string applicationName, ImmutableArray rootNames, HealthState healthState) + { + var (healthIcon, healthColor) = HealthModelIconHelpers.GetHealthStateIcon(healthState); + + return new ResourceDto + { + Name = AppHostEntityName, + ResourceType = ControlsStrings.ResourceGraphAppHostType, + DisplayName = applicationName, + Uid = AppHostEntityName, + ResourceIcon = new IconDto + { + Path = GetIconPathData(new Icons.Filled.Size24.AppFolder()), + Color = "var(--neutral-foreground-rest)", + Tooltip = ControlsStrings.ResourceGraphAppHostType + }, + StateIcon = new IconDto + { + Path = GetIconPathData(healthIcon), + Color = healthColor.ToAttributeValue()!, + Tooltip = healthState.ToString() + }, + ChildNames = rootNames, + HealthState = healthState.ToString(), + EndpointUrl = null, + EndpointText = null + }; + } + public static ResourceDto MapResource( ResourceViewModel r, IDictionary resourcesByName, diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 23af58cf383..a05c6e51627 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -897,6 +897,15 @@ public static string ResourceGraphNoEndpoints { } } + /// + /// Looks up a localized string similar to App host. + /// + public static string ResourceGraphAppHostType { + get { + return ResourceManager.GetString("ResourceGraphAppHostType", resourceCulture); + } + } + /// /// Looks up a localized string similar to Health checks. /// diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index a62189408a8..6e0343c29d0 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -474,6 +474,9 @@ No endpoints + + App host + Pause incoming data diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf index ee195ea29de..4f664fa7817 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf @@ -152,6 +152,11 @@ Exportovat .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf index 4a6670a7879..f3f5818e5a9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf @@ -152,6 +152,11 @@ .env exportieren + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf index 5cb537b3ac9..93730291ce5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf @@ -152,6 +152,11 @@ Exportar .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf index d553df2938e..a1ff21e3c7b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf @@ -152,6 +152,11 @@ Exporter le fichier .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf index b07fa223d3a..c586e69cfb0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf @@ -152,6 +152,11 @@ Esporta .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf index e64c85a11b5..88bd6ecb705 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf @@ -152,6 +152,11 @@ .env のエクスポート + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf index 463da94c217..b39eee8f2d1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf @@ -152,6 +152,11 @@ .env 내보내기 + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf index 5c5895bf0ce..d8fa0733120 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf @@ -152,6 +152,11 @@ Eksportuj .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf index 0bee9e34831..dcb650f029a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf @@ -152,6 +152,11 @@ Exportar .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf index db31c786648..7fd09827fd9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf @@ -152,6 +152,11 @@ Экспорт .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf index e8baff5c4ca..6e485ca8d89 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf @@ -152,6 +152,11 @@ ENV dosyasını dışarı aktar + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf index 8789f7b42b8..9cbb757d111 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf @@ -152,6 +152,11 @@ 导出 .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf index df88b00d9e8..429e2081f85 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf @@ -152,6 +152,11 @@ 匯出 .env + + App host + App host + + Graph. For an accessible view please navigate to the Resources tab Graph. For an accessible view please navigate to the Resources tab diff --git a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js index ac652fd1598..d172aeb91d9 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js @@ -1,5 +1,11 @@ import './d3.v7.min.js' +// Layout constants. The node circle is r=56 with its label sitting below it, so the collision radius is +// wider than the circle to keep labels from colliding as well. +const NODE_COLLIDE_RADIUS = 92; +const LAYER_HEIGHT = 230; +const SIBLING_SPACING = 210; + let resourceGraph = null; export function initializeResourcesGraph(resourcesInterop, graphIcons) { @@ -10,6 +16,13 @@ export function initializeResourcesGraph(resourcesInterop, graphIcons) { resourceGraph.resize(); }); + // The graph container is what actually bounds the drawing area, but it starts hidden while another tab + // is selected, so the summary layout is observed too to catch the switch back to the graph. + const graphContainer = document.querySelector('.resource-graph-container'); + if (graphContainer) { + observer.observe(graphContainer); + } + for (const child of document.getElementsByClassName('resources-summary-layout')) { observer.observe(child); } @@ -42,11 +55,25 @@ class ResourceGraph { this.svg = d3.select('.resource-graph'); this.baseGroup = this.svg.append("g"); + // Set while a node is being dragged. Collision is skipped for that node so it can be moved freely + // over the top of others instead of being shouldered away by them. + this.draggingNodeId = null; + + // The view is auto-fitted to the graph until the user zooms or pans, after which their framing is + // left alone. + this.userAdjustedView = false; + // Enable zoom + pan // https://www.d3indepth.com/zoom-and-pan/ // scaleExtent limits zoom to reasonable values - this.zoom = d3.zoom().scaleExtent([0.2, 4]).on('zoom', (event) => { + this.zoom = d3.zoom().scaleExtent([0.1, 4]).on('zoom', (event) => { this.baseGroup.attr('transform', event.transform); + + // sourceEvent is only set when the transform came from a real gesture, so programmatic + // auto-fitting doesn't count as the user taking control of the framing. + if (event.sourceEvent) { + this.userAdjustedView = true; + } }); this.svg.call(this.zoom); @@ -54,30 +81,24 @@ class ResourceGraph { this.linkForce = d3 .forceLink() .id(function (link) { return link.id }) - .strength(1.0) - .distance(function (link) { - // adaptive distance: longer for highly connected nodes - const sourceDegree = link.source.degree || 1; - const targetDegree = link.target.degree || 1; - const maxDegree = Math.max(sourceDegree, targetDegree); - - // scale distance with degree: 150 for low degree, up to 250 for high degree - return Math.min(150 + (maxDegree * 10), 250); - }); + .strength(0.25) + .distance(LAYER_HEIGHT); this.simulation = d3 .forceSimulation() .force('link', this.linkForce) - .force('charge', d3.forceManyBody().strength(-800)) - .force("collide", d3.forceCollide(function (d) { - var degree = d.degree || 1; - - // scale collide radius with degree: 90 for low degree, up to 180 for high degree - return Math.min(90 + (degree * 10), 180); - }).iterations(10)) - .force("x", d3.forceX().strength(0.2)) - .force("y", d3.forceY().strength(0.4)) - .force("center", d3.forceCenter().strength(0.01)); + .force('charge', d3.forceManyBody().strength(-900).distanceMax(700)) + .force("collide", d3.forceCollide((node) => { + // A node being dragged has no collision radius, so it slides over its neighbours instead of + // pushing them around. Everything else keeps a radius, which is what stops nodes from + // sitting on top of each other once the graph is static again. + return node.id === this.draggingNodeId ? 0 : NODE_COLLIDE_RADIUS; + }).iterations(4)) + // These two are what turn a floating force layout into a hierarchy. Y is pinned hard to the + // node's depth so every generation forms a row, while X only nudges each node toward the slot + // computed for it so collision can still spread crowded rows out. + .force("y", d3.forceY((node) => node.targetY || 0).strength(1)) + .force("x", d3.forceX((node) => node.targetX || 0).strength(0.25)); // Drag start is trigger on mousedown from click. // Only change the state of the simulation when the drag event is triggered. @@ -85,6 +106,12 @@ class ResourceGraph { var dragged = false; this.dragDrop = d3.drag().on('start', (event) => { dragActive = event.active; + dragged = false; + + // Reset defensively. If a previous gesture never delivered its end event (the browser losing + // focus mid-drag will do this) the node would otherwise stay excluded from collision forever. + this.draggingNodeId = null; + event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; }).on('drag', (event) => { @@ -92,18 +119,32 @@ class ResourceGraph { this.simulation.alphaTarget(0.1).restart(); dragActive = true; } - dragged = true; + if (!dragged) { + dragged = true; + + // Drop the node out of collision for the duration of the drag so it can be moved anywhere, + // including straight over other nodes, rather than being blocked by whatever is nearby. + this.draggingNodeId = event.subject.id; + } event.subject.fx = event.x; event.subject.fy = event.y; }).on('end', (event) => { if (dragged) { this.simulation.alphaTarget(0); dragged = false; + this.draggingNodeId = null; // Keep fx/fy so the node stays where it was dropped instead of springing back to wherever // the simulation wants it. Double clicking the node releases it again. event.subject.pinned = true; + + // Collision can't move a node that is fixed in place, so anything else already pinned on + // this spot would stay overlapped forever. Release those back to the simulation and let it + // push them clear, which keeps the most recent drop as the one that wins. + this.releasePinnedNodesOverlapping(event.subject); + this.updateNodePinnedState(); + this.simulation.alpha(0.4).restart(); } else { // Mousedown without movement is a click, not a drag, so release the temporary fixing applied @@ -116,6 +157,21 @@ class ResourceGraph { }); var defs = this.svg.append("defs"); + + // Dot grid that sits under the graph and pans/zooms with it, so the canvas the nodes live on is + // visible and it's obvious how far the content extends when dragging around. + var gridPattern = defs.append("pattern") + .attr("id", "resource-graph-grid") + .attr("patternUnits", "userSpaceOnUse") + .attr("width", "40") + .attr("height", "40"); + gridPattern + .append("circle") + .attr("cx", "2") + .attr("cy", "2") + .attr("r", "1.5") + .attr("class", "resource-graph-grid-dot"); + this.createArrowMarker(defs, "arrow-normal", "arrow-normal", 10, 10, 66); this.createArrowMarker(defs, "arrow-highlight", "arrow-highlight", 15, 15, 48); this.createArrowMarker(defs, "arrow-highlight-expand", "arrow-highlight-expand", 15, 15, 56); @@ -144,6 +200,17 @@ class ResourceGraph { .attr("stroke", "var(--neutral-fill-secondary-hover)") .attr("stroke-width", "15"); + // The grid is deliberately much larger than any realistic graph so panning never runs off the edge + // of the drawn canvas. + this.baseGroup + .insert("rect", ":first-child") + .attr("class", "resource-graph-background") + .attr("x", -20000) + .attr("y", -20000) + .attr("width", 40000) + .attr("height", 40000) + .attr("fill", "url(#resource-graph-grid)"); + this.linkElementsG = this.baseGroup.append("g").attr("class", "links"); this.nodeElementsG = this.baseGroup.append("g").attr("class", "nodes"); @@ -162,8 +229,7 @@ class ResourceGraph { } // Releases every pinned node so the layout is driven by the simulation again. - unpinAllNodes() { - var hasPinnedNodes = false; + unpinAllNodes() { var hasPinnedNodes = false; for (const node of this.nodes) { if (node.pinned) { node.pinned = false; @@ -188,6 +254,181 @@ class ResourceGraph { this.nodeElements.classed("resource-group-pinned", n => !!n.pinned); } + // Unpins any node that the supplied node has been dropped on top of. Collision alone can't separate two + // pinned nodes because neither is free to move, so the older pin yields to the newer one. + releasePinnedNodesOverlapping(node) { + const minimumDistance = NODE_COLLIDE_RADIUS * 2; + + for (const other of this.nodes) { + if (other === node || !other.pinned) { + continue; + } + + const dx = (other.x || 0) - (node.x || 0); + const dy = (other.y || 0) - (node.y || 0); + if (Math.sqrt(dx * dx + dy * dy) < minimumDistance) { + other.pinned = false; + other.fx = null; + other.fy = null; + } + } + } + + /* + * Works out where each node belongs in the hierarchy. + * + * The graph is a DAG rather than a tree, because a resource can be depended on by several others. To lay + * it out as a readable hierarchy each node is assigned to the first parent that reaches it in a breadth + * first walk from the roots, which produces a spanning tree. Links to additional parents still render; + * they just don't get a say in where the node sits. + * + * Depth becomes a fixed row (targetY) and the spanning tree drives a tidy left-to-right ordering + * (targetX): leaves are laid out in order and each parent is centred over its own children. + */ + computeHierarchy() { + const nodesById = new Map(this.nodes.map(n => [n.id, n])); + const childIds = new Map(); + const hasParent = new Set(); + + for (const link of this.links) { + const source = linkEndId(link.source); + const target = linkEndId(link.target); + if (!nodesById.has(source) || !nodesById.has(target)) { + continue; + } + + if (!childIds.has(source)) { + childIds.set(source, []); + } + childIds.get(source).push(target); + hasParent.add(target); + } + + const roots = this.nodes.filter(n => !hasParent.has(n.id)); + + const depth = new Map(); + const treeChildren = new Map(); + const visited = new Set(); + const queue = []; + + for (const root of roots) { + visited.add(root.id); + depth.set(root.id, 0); + queue.push(root.id); + } + + // Any node left unvisited is only reachable through a cycle, so promote it to a root of its own + // rather than leaving it without a position. + for (const node of this.nodes) { + if (!visited.has(node.id)) { + visited.add(node.id); + depth.set(node.id, 0); + roots.push(node); + queue.push(node.id); + } + + // Walk what is reachable so far before considering the next unvisited node, otherwise every + // member of a cycle gets promoted instead of just the first one. + while (queue.length > 0) { + const current = queue.shift(); + const currentDepth = depth.get(current); + + for (const child of (childIds.get(current) || [])) { + if (visited.has(child)) { + continue; + } + + visited.add(child); + depth.set(child, currentDepth + 1); + + if (!treeChildren.has(current)) { + treeChildren.set(current, []); + } + treeChildren.get(current).push(child); + queue.push(child); + } + } + } + + const xById = new Map(); + let nextLeafSlot = 0; + + const assignX = (id) => { + const children = treeChildren.get(id); + if (!children || children.length === 0) { + const x = nextLeafSlot * SIBLING_SPACING; + nextLeafSlot++; + xById.set(id, x); + return x; + } + + const childXs = children.map(assignX); + const x = (Math.min(...childXs) + Math.max(...childXs)) / 2; + xById.set(id, x); + return x; + }; + + for (const root of roots) { + assignX(root.id); + } + + // Centre the laid out tree on the origin so the initial view is balanced. + const allX = [...xById.values()]; + const xOffset = allX.length > 0 ? (Math.min(...allX) + Math.max(...allX)) / 2 : 0; + const maxDepth = Math.max(0, ...depth.values()); + const yOffset = (maxDepth * LAYER_HEIGHT) / 2; + + for (const node of this.nodes) { + node.depth = depth.get(node.id) || 0; + node.targetX = (xById.get(node.id) || 0) - xOffset; + node.targetY = (node.depth * LAYER_HEIGHT) - yOffset; + + // Seed brand new nodes on their target so the first frame is already laid out as a hierarchy + // instead of animating in from the middle of the canvas. + if (node.x === undefined || node.y === undefined) { + node.x = node.targetX; + node.y = node.targetY; + } + } + + function linkEndId(end) { + return typeof end === "object" ? end.id : end; + } + } + + // Frames the whole graph in the viewport. Skipped once the user has zoomed or panned so their framing + // isn't yanked away when a resource changes state. + fitToView() { + if (this.userAdjustedView || this.nodes.length === 0) { + return; + } + + const container = document.querySelector(".resource-graph-container"); + if (!container || container.clientWidth === 0) { + return; + } + + const padding = NODE_COLLIDE_RADIUS; + const xs = this.nodes.map(n => n.x || 0); + const ys = this.nodes.map(n => n.y || 0); + const minX = Math.min(...xs) - padding; + const maxX = Math.max(...xs) + padding; + const minY = Math.min(...ys) - padding; + const maxY = Math.max(...ys) + padding; + + const width = Math.max(maxX - minX, 1); + const height = Math.max(maxY - minY, 1); + + // Never scale up past 1. A small graph should sit at natural size in the middle rather than being + // blown up to fill the panel. + const scale = Math.min(1, container.clientWidth / width, container.clientHeight / height); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const transform = d3.zoomIdentity.scale(scale).translate(-centerX, -centerY); + this.svg.call(this.zoom.transform, transform); + } + zoomIn() { this.svg.transition().call(this.zoom.scaleBy, 1.5); } @@ -212,11 +453,15 @@ class ResourceGraph { } resize() { - var container = document.querySelector(".resources-summary-layout"); - if (container) { + // Measure the graph container rather than the whole summary panel. The panel also contains the tabs + // row, so measuring it made the drawing area taller than the space the graph actually occupies. + var container = document.querySelector(".resource-graph-container"); + if (container && container.clientWidth > 0 && container.clientHeight > 0) { var width = container.clientWidth; - var height = Math.max(container.clientHeight - 50, 0); + var height = container.clientHeight; this.svg.attr("viewBox", [-width / 2, -height / 2, width, height]); + + this.fitToView(); } } @@ -369,6 +614,10 @@ class ResourceGraph { this.links.push(...resourceLinks); } + // Positions have to be resolved before the nodes are rendered so brand new nodes can be seeded on + // their place in the hierarchy rather than flying in from the origin. + this.computeHierarchy(); + // Update nodes this.nodeElements = this.nodeElementsG .selectAll(".resource-group") @@ -598,6 +847,9 @@ class ResourceGraph { for (let i = 0; i < 300; i++) { this.simulation.tick(); } + + this.onTick(); + this.fitToView(); } this.simulation.restart(); diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs index 92b867a14ae..9f1b9b00b85 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.Xml.Linq; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.HealthModel; using Aspire.Dashboard.Model.ResourceGraph; using Aspire.Dashboard.Resources; using Aspire.Tests.Shared.DashboardModel; @@ -27,14 +28,23 @@ private ResourceDto MapSingle( bool showHiddenResources, IReadOnlyList? graphResources = null) { - var dtos = ResourceGraphMapper.MapResources( + var dtos = Map(resources, showHiddenResources, graphResources); + + return Assert.Single(dtos, d => d.Name == resource.Name); + } + + private List Map( + Dictionary resources, + bool showHiddenResources, + IReadOnlyList? graphResources = null) + { + return ResourceGraphMapper.MapResources( graphResources ?? [.. resources.Values], resources, new TestStringLocalizer(), showHiddenResources, - _iconResolver); - - return Assert.Single(dtos, d => d.Name == resource.Name); + _iconResolver, + applicationName: "TestApp"); } [Fact] @@ -179,6 +189,75 @@ public void MapResource_ReferenceToResourceExcludedFromGraph_Ignored() Assert.Empty(dto.ChildNames); } + [Fact] + public void MapResources_AddsAppHostRootThatParentsEveryTopLevelResource() + { + // api depends on db, so only api hangs off the app host. db is reached through api. + var api = ModelTestHelpers.CreateResource("api", displayName: "api", relationships: [new RelationshipViewModel("db", "Reference")]); + var db = ModelTestHelpers.CreateResource("db", displayName: "db", relationships: ImmutableArray.Empty); + var standalone = ModelTestHelpers.CreateResource("worker", displayName: "worker", relationships: ImmutableArray.Empty); + var resources = new Dictionary + { + [api.Name] = api, + [db.Name] = db, + [standalone.Name] = standalone, + }; + + var dtos = Map(resources, showHiddenResources: false); + + var appHost = dtos[0]; + Assert.Equal(ResourceGraphMapper.AppHostEntityName, appHost.Name); + Assert.Equal("TestApp", appHost.DisplayName); + Assert.Collection(appHost.ChildNames, + n => Assert.Equal("api", n), + n => Assert.Equal("worker", n)); + } + + [Fact] + public void MapResources_NoResources_HasNoAppHostRoot() + { + var dtos = Map([], showHiddenResources: false); + + Assert.Empty(dtos); + } + + [Fact] + public void MapResources_AppHostHealth_IsTheWorstAcrossTheGraph() + { + var api = ModelTestHelpers.CreateResource("api", displayName: "api", state: KnownResourceState.Running, relationships: [new RelationshipViewModel("db", "Reference")]); + var db = ModelTestHelpers.CreateResource("db", displayName: "db", state: KnownResourceState.FailedToStart, relationships: ImmutableArray.Empty); + var resources = new Dictionary + { + [api.Name] = api, + [db.Name] = db, + }; + + var dtos = Map(resources, showHiddenResources: false); + + // The failure is two levels down, so it has to roll through api before it reaches the app host. + Assert.Equal(nameof(HealthState.Unhealthy), dtos[0].HealthState); + } + + [Fact] + public void MapResources_CyclicResources_AreStillReachableFromTheAppHost() + { + // Every resource in a cycle has a parent, so without special handling none of them would be a root + // and they would float with no path back to the top of the graph. + var a = ModelTestHelpers.CreateResource("a", displayName: "a", relationships: [new RelationshipViewModel("b", "Reference")]); + var b = ModelTestHelpers.CreateResource("b", displayName: "b", relationships: [new RelationshipViewModel("a", "Reference")]); + var resources = new Dictionary + { + [a.Name] = a, + [b.Name] = b, + }; + + var dtos = Map(resources, showHiddenResources: false); + + // Only one member of the cycle is lifted to the app host; the other is reached through it. + var rootName = Assert.Single(dtos[0].ChildNames); + Assert.Contains(rootName, new[] { "a", "b" }); + } + [Fact] public void GetIconPathData_SinglePath_ReturnsPathData() { From 95b51ac8d61dbecbeb73279704d38b8c8f20730f Mon Sep 17 00:00:00 2001 From: James Gould Date: Mon, 7 Sep 2026 21:48:45 +0100 Subject: [PATCH 23/28] added explicit playground --- Aspire.slnx | 3 ++ .../HealthChecksSandbox.AppHost/AppHost.cs | 40 +++---------------- 2 files changed, 8 insertions(+), 35 deletions(-) diff --git a/Aspire.slnx b/Aspire.slnx index d31ec5ae046..d6cb5778e9b 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -286,6 +286,9 @@ + + + diff --git a/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs b/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs index 980ab09af59..d40020216b2 100644 --- a/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs +++ b/playground/HealthChecks/HealthChecksSandbox.AppHost/AppHost.cs @@ -11,39 +11,8 @@ builder.Services.TryAddEventingSubscriber(); AddTestResource("healthy", HealthStatus.Healthy, "I'm fine, thanks for asking."); -var unhealthyResource = AddTestResource("unhealthy", HealthStatus.Unhealthy, "I can't do that, Dave.", exceptionMessage: "Feeling unhealthy."); -var degradedResource = AddTestResource("degraded", HealthStatus.Degraded, "Had better days.", exceptionMessage: "Feeling degraded."); - -// ----------------------------------------------------------------------- -// A small service topology so the resource graph has a hierarchy to draw and health has somewhere to roll -// up to. Every resource here is healthy in its own right, so whatever state they end up showing in the -// graph has been inherited from something they depend on: -// -// storefront ──> checkout-api ──> orders-db ──> orders-db-server (parent/child) -// │ └─> session-cache ──> unhealthy -// └─> catalog-api ──> session-cache -// └─> degraded -// -// storefront therefore ends up unhealthy through the cache, and catalog-api degraded. -// ----------------------------------------------------------------------- -var ordersDbServer = AddTestResource("orders-db-server", HealthStatus.Healthy, "Serving databases."); -var ordersDb = AddTestResource("orders-db", HealthStatus.Healthy, "Accepting queries."); -ordersDb.WithParentRelationship(ordersDbServer); - -var sessionCache = AddTestResource("session-cache", HealthStatus.Healthy, "Cache warm."); -sessionCache.WithReferenceRelationship(unhealthyResource); - -var checkoutApi = AddTestResource("checkout-api", HealthStatus.Healthy, "Taking orders."); -checkoutApi.WithReferenceRelationship(ordersDb) - .WithReferenceRelationship(sessionCache); - -var catalogApi = AddTestResource("catalog-api", HealthStatus.Healthy, "Listing products."); -catalogApi.WithReferenceRelationship(sessionCache) - .WithReferenceRelationship(degradedResource); - -AddTestResource("storefront", HealthStatus.Healthy, "Serving customers.") - .WithReferenceRelationship(checkoutApi) - .WithReferenceRelationship(catalogApi); +AddTestResource("unhealthy", HealthStatus.Unhealthy, "I can't do that, Dave.", exceptionMessage: "Feeling unhealthy."); +AddTestResource("degraded", HealthStatus.Degraded, "Had better days.", exceptionMessage: "Feeling degraded."); // ----------------------------------------------------------------------- // External services with HTTP health checks to test friendly error messages @@ -117,7 +86,7 @@ builder.Build().Run(); -IResourceBuilder AddTestResource(string name, HealthStatus status, string? description = null, string? exceptionMessage = null) +void AddTestResource(string name, HealthStatus status, string? description = null, string? exceptionMessage = null) { var hasHealthyAfterFirstRunCheckRun = false; builder.Services.AddHealthChecks() @@ -136,7 +105,7 @@ IResourceBuilder AddTestResource(string name, HealthStatus status, return new HealthCheckResult(HealthStatus.Healthy, "Healthy beginning second health check run."); }); - return builder + builder .AddResource(new TestResource(name)) .WithHealthCheck($"{name}_check") .WithHealthCheck($"{name}_resource_healthy_after_first_run_check") @@ -147,6 +116,7 @@ IResourceBuilder AddTestResource(string name, HealthStatus status, Properties = [], }) .ExcludeFromManifest(); + return; } internal sealed class TestResource(string name) : Resource(name), IResourceWithEndpoints; From 439792ee6297d521710322fd0390c6d340b8a669 Mon Sep 17 00:00:00 2001 From: James Gould Date: Mon, 7 Sep 2026 21:51:33 +0100 Subject: [PATCH 24/28] broken commit --- playground/HealthModel/.vscode/launch.json | 11 + .../HealthModelSandbox.AppHost/AppHost.cs | 141 ++++++++ .../HealthModelSandbox.AppHost.csproj | 20 ++ .../Properties/launchSettings.json | 36 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + playground/HealthModel/aspire.config.json | 5 + .../ResourceGraph/ResourceGraphHealth.cs | 305 +++++++++++++++++ .../Model/ResourceGraphHealthTests.cs | 311 ++++++++++++++++++ 9 files changed, 846 insertions(+) create mode 100644 playground/HealthModel/.vscode/launch.json create mode 100644 playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs create mode 100644 playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj create mode 100644 playground/HealthModel/HealthModelSandbox.AppHost/Properties/launchSettings.json create mode 100644 playground/HealthModel/HealthModelSandbox.AppHost/appsettings.Development.json create mode 100644 playground/HealthModel/HealthModelSandbox.AppHost/appsettings.json create mode 100644 playground/HealthModel/aspire.config.json create mode 100644 src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs create mode 100644 tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs diff --git a/playground/HealthModel/.vscode/launch.json b/playground/HealthModel/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/HealthModel/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs b/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs new file mode 100644 index 00000000000..8569586dc8d --- /dev/null +++ b/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +// Playground for the health model and the resource graph. +// +// The topology below is a strict tree: every resource has exactly one parent, and nothing is shared between +// branches. That is deliberate. A shared dependency turns the model into a graph, which is legitimate but +// makes it much harder to see at a glance whether health is rolling up correctly, because a single unhealthy +// leaf lights up several unrelated-looking paths at once. +// +// storefront (Healthy) web front end +// ├── checkout-api (Healthy) +// │ ├── orders-db (Healthy) database, child of its server +// │ │ └── orders-db-server (Healthy) +// │ └── payments-gateway (Degraded) <- degrades the checkout branch +// ├── catalog-api (Healthy) +// │ ├── catalog-db (Healthy) +// │ │ └── catalog-db-server (Healthy) +// │ └── search-index (Unhealthy) <- fails the catalog branch +// └── identity-api (Healthy) fully healthy branch +// └── identity-cache (Healthy) +// +// Only three resources are anything other than healthy, so every other colour in the graph has been +// inherited. The expected result once everything has started: +// +// storefront Unhealthy (worst of its branches, via catalog-api -> search-index) +// checkout-api Degraded (via payments-gateway) +// catalog-api Unhealthy (via search-index) +// identity-api Healthy (nothing unhealthy underneath it) + +var builder = DistributedApplication.CreateBuilder(args); + +builder.Services.TryAddEventingSubscriber(); + +// Checkout branch. The database hangs off its server as a parent/child pair, which is how a real database +// integration models itself, so the graph gets a three-level chain to lay out. +var ordersDbServer = AddTestResource("orders-db-server", HealthStatus.Healthy, "Accepting connections."); +var ordersDb = AddTestResource("orders-db", HealthStatus.Healthy, "Migrations applied.") + .WithParentRelationship(ordersDbServer); + +var paymentsGateway = AddTestResource("payments-gateway", HealthStatus.Degraded, "Elevated latency from the payment provider."); + +var checkoutApi = AddTestResource("checkout-api", HealthStatus.Healthy, "Accepting orders.") + .WithReferenceRelationship(ordersDb) + .WithReferenceRelationship(paymentsGateway); + +// Catalog branch. +var catalogDbServer = AddTestResource("catalog-db-server", HealthStatus.Healthy, "Accepting connections."); +var catalogDb = AddTestResource("catalog-db", HealthStatus.Healthy, "Migrations applied.") + .WithParentRelationship(catalogDbServer); + +var searchIndex = AddTestResource("search-index", HealthStatus.Unhealthy, "Index rebuild failed.", exceptionMessage: "Shard 3 is offline."); + +var catalogApi = AddTestResource("catalog-api", HealthStatus.Healthy, "Serving product data.") + .WithReferenceRelationship(catalogDb) + .WithReferenceRelationship(searchIndex); + +// Identity branch, kept entirely healthy so there is a green path to compare the other two against. +var identityCache = AddTestResource("identity-cache", HealthStatus.Healthy, "Cache warm."); + +var identityApi = AddTestResource("identity-api", HealthStatus.Healthy, "Issuing tokens.") + .WithReferenceRelationship(identityCache); + +AddTestResource("storefront", HealthStatus.Healthy, "Serving customers.") + .WithReferenceRelationship(checkoutApi) + .WithReferenceRelationship(catalogApi) + .WithReferenceRelationship(identityApi); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); + +IResourceBuilder AddTestResource(string name, HealthStatus status, string? description = null, string? exceptionMessage = null) +{ + builder.Services.AddHealthChecks() + .AddCheck( + $"{name}_check", + () => new HealthCheckResult(status, description, exceptionMessage is null ? null : new InvalidOperationException(exceptionMessage))); + + return builder + .AddResource(new TestResource(name)) + .WithHealthCheck($"{name}_check") + .WithInitialState(new() + { + ResourceType = "Test Resource", + State = "Starting", + Properties = [], + }) + .ExcludeFromManifest(); +} + +internal sealed class TestResource(string name) : Resource(name), IResourceWithEndpoints; + +/// +/// Moves the test resources to the running state shortly after startup. +/// +/// +/// The delay is intentional. It leaves the resources in an unknown state for long enough to see that a +/// starting resource does not colour the graph, which is the behaviour that keeps the model from flashing +/// red every time the app host boots. +/// +internal sealed class TestResourceLifecycle(ResourceNotificationService notificationService) : IDistributedApplicationEventingSubscriber +{ + public Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken) + { + foreach (var resource in @event.Model.Resources.OfType()) + { + Task.Run( + async () => + { + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + + await notificationService.PublishUpdateAsync( + resource, + state => state with { State = new("Running", "success") }); + }, + cancellationToken); + } + + return Task.CompletedTask; + } + + public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + eventing.Subscribe(OnBeforeStartAsync); + return Task.CompletedTask; + } +} diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj b/playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj new file mode 100644 index 00000000000..6854f056c4e --- /dev/null +++ b/playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + 0e5a0d0e-5b1c-4d0a-9d5f-2c4b6a8f1e73 + + + + + + + + + + + diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/Properties/launchSettings.json b/playground/HealthModel/HealthModelSandbox.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..362b495c6b5 --- /dev/null +++ b/playground/HealthModel/HealthModelSandbox.AppHost/Properties/launchSettings.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://HealthModel.dev.localhost:17420;http://HealthModel.dev.localhost:15320", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://HealthModel.dev.localhost:15320", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://HealthModel.dev.localhost:16320", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/appsettings.Development.json b/playground/HealthModel/HealthModelSandbox.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/HealthModel/HealthModelSandbox.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/appsettings.json b/playground/HealthModel/HealthModelSandbox.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/HealthModel/HealthModelSandbox.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/HealthModel/aspire.config.json b/playground/HealthModel/aspire.config.json new file mode 100644 index 00000000000..249ca39a5f7 --- /dev/null +++ b/playground/HealthModel/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj" + } +} diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs new file mode 100644 index 00000000000..c2970bf94a7 --- /dev/null +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs @@ -0,0 +1,305 @@ +// 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.Immutable; +using Aspire.Dashboard.Model.HealthModel; + +namespace Aspire.Dashboard.Model.ResourceGraph; + +/// +/// A directed parent-to-child edge in the resource graph, derived from an app host dependency. +/// +/// The name of the resource that depends on . +/// The name of the resource being depended on. +public readonly record struct ResourceGraphEdge(string ParentName, string ChildName); + +/// +/// Derives the parent-child structure of the resource graph from app host dependencies and rolls child +/// health up through it. +/// +/// +/// +/// The rollup deliberately reuses and its worst-of semantics so the graph and the +/// health model agree about what a resource's state means. In particular +/// is the least severe state, so a dependency that has not reported yet never drags its dependents down. +/// +/// +/// Edges point from the resource that depends on something to the thing it depends on, so health flows from +/// leaves toward the roots of the graph: if a database is unhealthy then the service that references it is +/// shown as unhealthy too, and so on up the chain. +/// +/// +public static class ResourceGraphHealth +{ + /// + /// Builds the parent-to-child edges between the supplied resources. + /// + /// The resources currently displayed in the graph. + /// Whether hidden resources are being displayed. + public static ImmutableArray BuildEdges(IEnumerable resources, bool showHiddenResources) + { + ArgumentNullException.ThrowIfNull(resources); + + var graphResources = resources.ToList(); + var edges = ImmutableArray.CreateBuilder(); + var seen = new HashSet(); + + foreach (var resource in graphResources) + { + foreach (var childName in GetChildNames(resource, graphResources, showHiddenResources)) + { + var edge = new ResourceGraphEdge(resource.Name, childName); + if (seen.Add(edge)) + { + edges.Add(edge); + } + } + + foreach (var parentName in GetParentNames(resource, graphResources, showHiddenResources)) + { + var edge = new ResourceGraphEdge(parentName, resource.Name); + if (seen.Add(edge)) + { + edges.Add(edge); + } + } + } + + return edges.ToImmutable(); + } + + /// + /// Gets the names of the resources that depends on, which become its + /// children in the graph. + /// + /// + /// A WaitFor or Reference relationship is declared on the dependent resource and + /// points at the thing it needs, so the relationship target is already the child. + /// + public static ImmutableArray GetChildNames(ResourceViewModel resource, IEnumerable graphResources, bool showHiddenResources) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(graphResources); + + return ResolveRelationships( + resource, + graphResources, + showHiddenResources, + static type => !string.Equals(type, KnownRelationshipTypes.Parent, StringComparison.Ordinal)); + } + + /// + /// Gets the names of the resources that hangs off, which become its parents + /// in the graph. + /// + /// + /// A Parent relationship is declared on the child and points at its parent, which is the + /// opposite direction to the dependency relationships, so these edges are flipped when they are built. + /// A database resource pointing at its server is the common case: the database is the child, so its + /// health rolls up into the server. + /// + public static ImmutableArray GetParentNames(ResourceViewModel resource, IEnumerable graphResources, bool showHiddenResources) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(graphResources); + + return ResolveRelationships( + resource, + graphResources, + showHiddenResources, + static type => string.Equals(type, KnownRelationshipTypes.Parent, StringComparison.Ordinal)); + } + + /// + /// Gets the resources that should hang directly off the synthetic app host root. + /// + /// + /// A resource is a root when nothing depends on it. Resources that only appear inside a dependency cycle + /// have a parent but are unreachable from any root, so they are attached to the app host too rather than + /// being left floating with no path back to the top of the graph. + /// + public static ImmutableArray GetRootNames(IEnumerable resources, ImmutableArray edges) + { + ArgumentNullException.ThrowIfNull(resources); + + var allNames = resources.Select(r => r.Name).ToList(); + var hasParent = edges.Select(e => e.ChildName).ToHashSet(StringComparers.ResourceName); + + var roots = allNames.Where(n => !hasParent.Contains(n)).ToList(); + + var childrenByParent = edges + .GroupBy(e => e.ParentName, StringComparers.ResourceName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ChildName).ToArray(), StringComparers.ResourceName); + + var reachable = new HashSet(StringComparers.ResourceName); + var pending = new Stack(roots); + while (pending.Count > 0) + { + var current = pending.Pop(); + if (!reachable.Add(current)) + { + continue; + } + + if (childrenByParent.TryGetValue(current, out var children)) + { + foreach (var child in children) + { + pending.Push(child); + } + } + } + + foreach (var name in allNames) + { + if (!reachable.Contains(name)) + { + roots.Add(name); + + // Mark everything below the newly promoted resource as reachable so only one member of a + // cycle is lifted to the app host instead of every member of it. + var cyclePending = new Stack([name]); + while (cyclePending.Count > 0) + { + var current = cyclePending.Pop(); + if (!reachable.Add(current)) + { + continue; + } + + if (childrenByParent.TryGetValue(current, out var children)) + { + foreach (var child in children) + { + cyclePending.Push(child); + } + } + } + } + } + + return [.. roots.OrderBy(n => n, StringComparers.ResourceName)]; + } + + /// + /// Computes the health state of every resource after child health has been rolled up into its parents. + /// + /// The resources currently displayed in the graph. + /// The parent-to-child edges between those resources. + /// A map of resource name to the state that should be displayed for it. + public static Dictionary ComputeEffectiveStates(IEnumerable resources, ImmutableArray edges) + { + ArgumentNullException.ThrowIfNull(resources); + + var ownStates = new Dictionary(StringComparers.ResourceName); + foreach (var resource in resources) + { + ownStates[resource.Name] = GetOwnState(resource); + } + + var childrenByParent = edges + .GroupBy(e => e.ParentName, StringComparers.ResourceName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ChildName).ToArray(), StringComparers.ResourceName); + + var effectiveStates = new Dictionary(StringComparers.ResourceName); + + // A resource can be reached through several paths, so results are memoized. The visiting set only + // guards the current path: dependency chains are not guaranteed to be acyclic once Reference + // relationships are involved, and a cycle would otherwise recurse forever. + var visiting = new HashSet(StringComparers.ResourceName); + + foreach (var name in ownStates.Keys) + { + Resolve(name); + } + + return effectiveStates; + + HealthState Resolve(string name) + { + if (effectiveStates.TryGetValue(name, out var cached)) + { + return cached; + } + + if (!ownStates.TryGetValue(name, out var state)) + { + return HealthState.Unknown; + } + + if (!visiting.Add(name)) + { + // Re-entering a resource already on the current path. Return its own state so the cycle + // contributes something without recursing, and leave the memo unset so the fully resolved + // value is still computed by the outer frame. + return state; + } + + try + { + if (childrenByParent.TryGetValue(name, out var children)) + { + foreach (var child in children) + { + state = HealthStateExtensions.WorstOf(state, Resolve(child)); + } + } + } + finally + { + visiting.Remove(name); + } + + effectiveStates[name] = state; + return state; + } + } + + /// + /// Gets the health state of a single resource, ignoring anything it depends on. + /// + internal static HealthState GetOwnState(ResourceViewModel resource) + { + ArgumentNullException.ThrowIfNull(resource); + + // HealthStatus is only populated while a resource is running, so fall back to the lifecycle state to + // catch resources that failed to start or exited. + var stateFromLifecycle = AspireHealthModelBuilder.MapResourceState(resource.KnownState); + + return resource.HealthStatus is { } healthStatus + ? HealthStateExtensions.WorstOf(stateFromLifecycle, AspireHealthModelBuilder.MapHealthStatus(healthStatus)) + : stateFromLifecycle; + } + + private static ImmutableArray ResolveRelationships( + ResourceViewModel resource, + IEnumerable graphResources, + bool showHiddenResources, + Func includeType) + { + // Relationships back to the resource itself are dropped. The graph doesn't display self referential + // edges, and treating one as a dependency would make the resource its own child. + var relationships = resource.Relationships + .Where(relationship => includeType(relationship.Type)) + .Where(relationship => !string.Equals(relationship.ResourceName, resource.DisplayName, StringComparisons.ResourceName)); + + var resolved = new List(); + + foreach (var group in relationships.GroupBy(r => r.ResourceName, StringComparers.ResourceName)) + { + // Relationships reference a resource by display name, which resolves to several resources when + // the target is replicated. Every replica becomes an edge. + var matches = graphResources + .Where(r => string.Equals(r.DisplayName, group.Key, StringComparisons.ResourceName)) + .Where(r => !r.IsResourceHidden(showHiddenResources)) + .Where(r => !string.Equals(r.Name, resource.Name, StringComparisons.ResourceName)); + + foreach (var match in matches) + { + resolved.Add(match.Name); + } + } + + return [.. resolved.Distinct(StringComparers.ResourceName).OrderBy(n => n, StringComparers.ResourceName)]; + } +} diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs new file mode 100644 index 00000000000..0342f983fd6 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs @@ -0,0 +1,311 @@ +// 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.Immutable; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.HealthModel; +using Aspire.Dashboard.Model.ResourceGraph; +using Aspire.Tests.Shared.DashboardModel; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +namespace Aspire.Dashboard.Tests.Model; + +public class ResourceGraphHealthTests +{ + [Fact] + public void BuildEdges_ReferenceRelationship_PointsFromDependentToDependency() + { + // api references db, so api depends on db. db is therefore api's child and its health rolls up. + var api = CreateResource("api", relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.Reference)]); + var db = CreateResource("db"); + + var edges = ResourceGraphHealth.BuildEdges([api, db], showHiddenResources: false); + + Assert.Equal(new ResourceGraphEdge("api", "db"), Assert.Single(edges)); + } + + [Fact] + public void BuildEdges_WaitForRelationship_PointsFromDependentToDependency() + { + var api = CreateResource("api", relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.WaitFor)]); + var db = CreateResource("db"); + + var edges = ResourceGraphHealth.BuildEdges([api, db], showHiddenResources: false); + + Assert.Equal(new ResourceGraphEdge("api", "db"), Assert.Single(edges)); + } + + [Fact] + public void BuildEdges_ParentRelationship_IsFlippedSoTheParentOwnsTheChild() + { + // A Parent relationship is declared on the child and points at its parent, which is the opposite + // direction to a dependency, so the edge has to be flipped. + var database = CreateResource("catalogdb", relationships: [new RelationshipViewModel("postgres", KnownRelationshipTypes.Parent)]); + var server = CreateResource("postgres"); + + var edges = ResourceGraphHealth.BuildEdges([database, server], showHiddenResources: false); + + Assert.Equal(new ResourceGraphEdge("postgres", "catalogdb"), Assert.Single(edges)); + } + + [Fact] + public void BuildEdges_SelfRelationship_Ignored() + { + var resource = CreateResource("api", relationships: [new RelationshipViewModel("api", KnownRelationshipTypes.Reference)]); + + var edges = ResourceGraphHealth.BuildEdges([resource], showHiddenResources: false); + + Assert.Empty(edges); + } + + [Fact] + public void BuildEdges_HiddenDependency_ExcludedUnlessShown() + { + var api = CreateResource("api", relationships: [new RelationshipViewModel("secret", KnownRelationshipTypes.Reference)]); + var hidden = CreateResource("secret", hidden: true); + + Assert.Empty(ResourceGraphHealth.BuildEdges([api, hidden], showHiddenResources: false)); + Assert.Equal( + new ResourceGraphEdge("api", "secret"), + Assert.Single(ResourceGraphHealth.BuildEdges([api, hidden], showHiddenResources: true))); + } + + [Fact] + public void BuildEdges_DuplicateRelationships_ProduceASingleEdge() + { + // WithReference followed by WaitFor records two relationships between the same pair of resources. + var api = CreateResource("api", relationships: + [ + new RelationshipViewModel("db", KnownRelationshipTypes.Reference), + new RelationshipViewModel("db", KnownRelationshipTypes.WaitFor) + ]); + var db = CreateResource("db"); + + var edges = ResourceGraphHealth.BuildEdges([api, db], showHiddenResources: false); + + Assert.Equal(new ResourceGraphEdge("api", "db"), Assert.Single(edges)); + } + + [Fact] + public void BuildEdges_ReplicatedDependency_ProducesAnEdgePerReplica() + { + var api = CreateResource("api", relationships: [new RelationshipViewModel("worker", KnownRelationshipTypes.Reference)]); + var worker1 = CreateResource("worker-abc", displayName: "worker"); + var worker2 = CreateResource("worker-def", displayName: "worker"); + + var edges = ResourceGraphHealth.BuildEdges([api, worker1, worker2], showHiddenResources: false); + + Assert.Collection(edges, + e => Assert.Equal(new ResourceGraphEdge("api", "worker-abc"), e), + e => Assert.Equal(new ResourceGraphEdge("api", "worker-def"), e)); + } + + [Theory] + [InlineData(KnownResourceState.Running, null, HealthState.Healthy)] + [InlineData(KnownResourceState.Running, HealthStatus.Degraded, HealthState.Degraded)] + [InlineData(KnownResourceState.Running, HealthStatus.Unhealthy, HealthState.Unhealthy)] + [InlineData(KnownResourceState.FailedToStart, null, HealthState.Unhealthy)] + [InlineData(KnownResourceState.Starting, null, HealthState.Unknown)] + public void GetOwnState_CombinesLifecycleStateAndHealthChecks(KnownResourceState state, HealthStatus? health, HealthState expected) + { + var resource = ModelTestHelpers.CreateResource(resourceName: "api", state: state, reportHealthStatus: health); + + Assert.Equal(expected, ResourceGraphHealth.GetOwnState(resource)); + } + + [Fact] + public void ComputeEffectiveStates_UnhealthyDependency_PropagatesToTheRoot() + { + // web -> api -> db, where only db is broken. The whole chain back to the root should show unhealthy. + var web = CreateResource("web", relationships: [new RelationshipViewModel("api", KnownRelationshipTypes.Reference)]); + var api = CreateResource("api", relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.Reference)]); + var db = CreateResource("db", state: KnownResourceState.FailedToStart); + + var states = Compute([web, api, db]); + + Assert.Equal(HealthState.Unhealthy, states["db"]); + Assert.Equal(HealthState.Unhealthy, states["api"]); + Assert.Equal(HealthState.Unhealthy, states["web"]); + } + + [Fact] + public void ComputeEffectiveStates_DegradedDependency_PropagatesAsDegraded() + { + var api = CreateResource("api", relationships: [new RelationshipViewModel("cache", KnownRelationshipTypes.Reference)]); + var cache = CreateResource("cache", health: HealthStatus.Degraded); + + var states = Compute([api, cache]); + + Assert.Equal(HealthState.Degraded, states["cache"]); + Assert.Equal(HealthState.Degraded, states["api"]); + } + + [Fact] + public void ComputeEffectiveStates_AllDependenciesHealthy_AggregatesToHealthy() + { + var api = CreateResource("api", relationships: + [ + new RelationshipViewModel("db", KnownRelationshipTypes.Reference), + new RelationshipViewModel("cache", KnownRelationshipTypes.Reference) + ]); + var db = CreateResource("db"); + var cache = CreateResource("cache"); + + var states = Compute([api, db, cache]); + + Assert.Equal(HealthState.Healthy, states["api"]); + } + + [Fact] + public void ComputeEffectiveStates_WorstDependencyWins() + { + var api = CreateResource("api", relationships: + [ + new RelationshipViewModel("db", KnownRelationshipTypes.Reference), + new RelationshipViewModel("cache", KnownRelationshipTypes.Reference) + ]); + var db = CreateResource("db", state: KnownResourceState.FailedToStart); + var cache = CreateResource("cache", health: HealthStatus.Degraded); + + var states = Compute([api, db, cache]); + + Assert.Equal(HealthState.Unhealthy, states["api"]); + } + + [Fact] + public void ComputeEffectiveStates_StartingDependency_DoesNotDragTheParentDown() + { + // Unknown is the least severe state, so a dependency that hasn't reported yet must leave a healthy + // parent healthy rather than making the graph look broken during startup. + var api = CreateResource("api", relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.Reference)]); + var db = CreateResource("db", state: KnownResourceState.Starting); + + var states = Compute([api, db]); + + Assert.Equal(HealthState.Unknown, states["db"]); + Assert.Equal(HealthState.Healthy, states["api"]); + } + + [Fact] + public void ComputeEffectiveStates_UnhealthyParent_DoesNotAffectItsChild() + { + // Health only flows upward. A broken consumer says nothing about the thing it consumes. + var api = CreateResource("api", state: KnownResourceState.FailedToStart, relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.Reference)]); + var db = CreateResource("db"); + + var states = Compute([api, db]); + + Assert.Equal(HealthState.Unhealthy, states["api"]); + Assert.Equal(HealthState.Healthy, states["db"]); + } + + [Fact] + public void ComputeEffectiveStates_DatabaseUnderServer_RollsUpIntoTheServer() + { + var server = CreateResource("postgres"); + var database = CreateResource("catalogdb", state: KnownResourceState.FailedToStart, relationships: [new RelationshipViewModel("postgres", KnownRelationshipTypes.Parent)]); + + var states = Compute([server, database]); + + Assert.Equal(HealthState.Unhealthy, states["postgres"]); + } + + [Fact] + public void ComputeEffectiveStates_CyclicDependencies_DoNotRecurseForever() + { + var a = CreateResource("a", relationships: [new RelationshipViewModel("b", KnownRelationshipTypes.Reference)]); + var b = CreateResource("b", state: KnownResourceState.FailedToStart, relationships: [new RelationshipViewModel("a", KnownRelationshipTypes.Reference)]); + + var states = Compute([a, b]); + + Assert.Equal(HealthState.Unhealthy, states["a"]); + Assert.Equal(HealthState.Unhealthy, states["b"]); + } + + [Fact] + public void ComputeEffectiveStates_DiamondDependency_ResolvesSharedLeafOnce() + { + // web depends on both api and worker, which both depend on db. db is reached by two paths. + var web = CreateResource("web", relationships: + [ + new RelationshipViewModel("api", KnownRelationshipTypes.Reference), + new RelationshipViewModel("worker", KnownRelationshipTypes.Reference) + ]); + var api = CreateResource("api", relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.Reference)]); + var worker = CreateResource("worker", relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.Reference)]); + var db = CreateResource("db", health: HealthStatus.Degraded); + + var states = Compute([web, api, worker, db]); + + Assert.Equal(HealthState.Degraded, states["api"]); + Assert.Equal(HealthState.Degraded, states["worker"]); + Assert.Equal(HealthState.Degraded, states["web"]); + } + + [Fact] + public void GetRootNames_ResourcesNothingDependsOn_AreRoots() + { + var api = CreateResource("api", relationships: [new RelationshipViewModel("db", KnownRelationshipTypes.Reference)]); + var db = CreateResource("db"); + var standalone = CreateResource("worker"); + + var edges = ResourceGraphHealth.BuildEdges([api, db, standalone], showHiddenResources: false); + var roots = ResourceGraphHealth.GetRootNames([api, db, standalone], edges); + + Assert.Collection(roots, + n => Assert.Equal("api", n), + n => Assert.Equal("worker", n)); + } + + [Fact] + public void GetRootNames_CycleWithNoEntryPoint_PromotesOneMemberToARoot() + { + // Both resources have a parent, so neither qualifies as a root on its own. Without promoting one of + // them the pair would be unreachable from the top of the graph. + var a = CreateResource("a", relationships: [new RelationshipViewModel("b", KnownRelationshipTypes.Reference)]); + var b = CreateResource("b", relationships: [new RelationshipViewModel("a", KnownRelationshipTypes.Reference)]); + + var edges = ResourceGraphHealth.BuildEdges([a, b], showHiddenResources: false); + var roots = ResourceGraphHealth.GetRootNames([a, b], edges); + + var root = Assert.Single(roots); + Assert.Contains(root, new[] { "a", "b" }); + } + + [Fact] + public void GetRootNames_CycleHangingOffARealRoot_DoesNotPromoteCycleMembers() + { + var entry = CreateResource("entry", relationships: [new RelationshipViewModel("a", KnownRelationshipTypes.Reference)]); + var a = CreateResource("a", relationships: [new RelationshipViewModel("b", KnownRelationshipTypes.Reference)]); + var b = CreateResource("b", relationships: [new RelationshipViewModel("a", KnownRelationshipTypes.Reference)]); + + var edges = ResourceGraphHealth.BuildEdges([entry, a, b], showHiddenResources: false); + var roots = ResourceGraphHealth.GetRootNames([entry, a, b], edges); + + Assert.Equal("entry", Assert.Single(roots)); + } + + private static Dictionary Compute(IReadOnlyList resources) + { + var edges = ResourceGraphHealth.BuildEdges(resources, showHiddenResources: false); + return ResourceGraphHealth.ComputeEffectiveStates(resources, edges); + } + + private static ResourceViewModel CreateResource( + string name, + string? displayName = null, + KnownResourceState state = KnownResourceState.Running, + HealthStatus? health = null, + bool hidden = false, + ImmutableArray? relationships = null) + { + return ModelTestHelpers.CreateResource( + resourceName: name, + displayName: displayName ?? name, + state: state, + reportHealthStatus: health, + hidden: hidden, + relationships: relationships ?? []); + } +} From 61c414f8ac2ab7bb98553ffe746e199a42cbffc2 Mon Sep 17 00:00:00 2001 From: James Gould Date: Sun, 13 Sep 2026 14:09:11 +0100 Subject: [PATCH 25/28] improvements to topology parsing and wiring between entities --- .../HealthModelSandbox.AppHost/AppHost.cs | 47 +- playground/HealthModel/README.md | 158 +++++++ .../Controls/HealthModelEntityDetails.razor | 129 +++-- .../HealthModelEntityDetails.razor.cs | 130 +++++ .../HealthModelEntityDetails.razor.css | 9 + .../Controls/HealthModelGraph.razor | 65 +++ .../Controls/HealthModelGraph.razor.cs | 153 ++++++ .../Controls/HealthModelGraph.razor.css | 53 +++ .../Components/Pages/HealthModel.razor | 220 +++++---- .../Components/Pages/HealthModel.razor.cs | 443 ++++++++++++++---- .../Components/Pages/HealthModel.razor.css | 65 +-- .../Components/Pages/Resources.razor | 4 +- .../Components/Pages/Resources.razor.cs | 65 ++- .../Components/Pages/Resources.razor.css | 11 +- .../HealthModel/AspireHealthModelBuilder.cs | 121 ++--- .../Model/HealthModel/HealthModelDocument.cs | 219 +++++++++ .../Model/HealthModel/HealthModelEntity.cs | 19 +- .../Model/HealthModel/HealthModelEvaluator.cs | 120 ++--- .../Model/HealthModel/HealthModelLabels.cs | 32 ++ .../Model/HealthModel/HealthModelLayout.cs | 88 ++++ .../Model/HealthModel/HealthModelTopology.cs | 55 +++ .../Model/ResourceGraph/ResourceDto.cs | 5 + .../ResourceGraph/ResourceGraphHealth.cs | 71 +-- .../ResourceGraph/ResourceGraphMapper.cs | 1 + .../Resources/HealthModel.Designer.cs | 128 +++++ .../Resources/HealthModel.resx | 64 +++ .../Resources/xlf/HealthModel.cs.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.de.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.es.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.fr.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.it.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.ja.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.ko.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.pl.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.pt-BR.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.ru.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.tr.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.zh-Hans.xlf | 320 +++++++++++++ .../Resources/xlf/HealthModel.zh-Hant.xlf | 320 +++++++++++++ .../wwwroot/js/app-healthmodel.js | 145 ++++++ .../wwwroot/js/app-resourcegraph.js | 334 +++++++------ src/Shared/DashboardUrls.cs | 6 +- .../Pages/HealthModelTests.cs | 106 ++++- .../Pages/ResourcesTests.cs | 3 + .../Shared/HealthModelSetupHelpers.cs | 25 + .../Shared/TestLocalStorage.cs | 3 +- .../Aspire.Dashboard.Tests.csproj | 3 + .../Playwright/HealthModelTests.cs | 184 ++++++++ .../Infrastructure/DashboardServerFixture.cs | 17 +- .../Infrastructure/MockDashboardClient.cs | 12 +- .../MockDashboardRepositoryFactory.cs | 16 + .../Infrastructure/PlaywrightFixture.cs | 10 +- .../Playwright/ResourceGraphTests.cs | 320 +++++++++++++ .../Model/AspireHealthModelBuilderTests.cs | 83 ++-- .../Model/HealthModelDocumentTests.cs | 169 +++++++ .../Model/HealthModelEvaluatorTests.cs | 21 +- .../Model/ResourceGraphHealthTests.cs | 72 +++ .../Model/ResourceGraphMapperTests.cs | 2 + ...cumentContainsOnlyDefinition.verified.json | 91 ++++ 59 files changed, 7549 insertions(+), 708 deletions(-) create mode 100644 playground/HealthModel/README.md create mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor create mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs create mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs create mode 100644 src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js create mode 100644 tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs create mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs create mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardRepositoryFactory.cs create mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourceGraphTests.cs create mode 100644 tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs create mode 100644 tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs b/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs index 8569586dc8d..0e862cfcb76 100644 --- a/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs +++ b/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs @@ -15,17 +15,17 @@ // // storefront (Healthy) web front end // ├── checkout-api (Healthy) -// │ ├── orders-db (Healthy) database, child of its server -// │ │ └── orders-db-server (Healthy) +// │ ├── orders-db-server (Healthy) +// │ │ └── orders-db (Healthy) child of its server // │ └── payments-gateway (Degraded) <- degrades the checkout branch // ├── catalog-api (Healthy) -// │ ├── catalog-db (Healthy) -// │ │ └── catalog-db-server (Healthy) +// │ ├── catalog-db-server (Healthy) +// │ │ └── catalog-db (Healthy) child of its server // │ └── search-index (Unhealthy) <- fails the catalog branch // └── identity-api (Healthy) fully healthy branch // └── identity-cache (Healthy) // -// Only three resources are anything other than healthy, so every other colour in the graph has been +// Only two resources are anything other than healthy, so every other colour in the graph has been // inherited. The expected result once everything has started: // // storefront Unhealthy (worst of its branches, via catalog-api -> search-index) @@ -37,27 +37,27 @@ builder.Services.TryAddEventingSubscriber(); -// Checkout branch. The database hangs off its server as a parent/child pair, which is how a real database -// integration models itself, so the graph gets a three-level chain to lay out. +// Reference the server in this simulated topology. Referencing the database as well as declaring its +// parent would give it two incoming edges and leave the server at the top level of the graph. var ordersDbServer = AddTestResource("orders-db-server", HealthStatus.Healthy, "Accepting connections."); -var ordersDb = AddTestResource("orders-db", HealthStatus.Healthy, "Migrations applied.") +AddTestResource("orders-db", HealthStatus.Healthy, "Migrations applied.") .WithParentRelationship(ordersDbServer); var paymentsGateway = AddTestResource("payments-gateway", HealthStatus.Degraded, "Elevated latency from the payment provider."); var checkoutApi = AddTestResource("checkout-api", HealthStatus.Healthy, "Accepting orders.") - .WithReferenceRelationship(ordersDb) + .WithReferenceRelationship(ordersDbServer) .WithReferenceRelationship(paymentsGateway); // Catalog branch. var catalogDbServer = AddTestResource("catalog-db-server", HealthStatus.Healthy, "Accepting connections."); -var catalogDb = AddTestResource("catalog-db", HealthStatus.Healthy, "Migrations applied.") +AddTestResource("catalog-db", HealthStatus.Healthy, "Migrations applied.") .WithParentRelationship(catalogDbServer); var searchIndex = AddTestResource("search-index", HealthStatus.Unhealthy, "Index rebuild failed.", exceptionMessage: "Shard 3 is offline."); var catalogApi = AddTestResource("catalog-api", HealthStatus.Healthy, "Serving product data.") - .WithReferenceRelationship(catalogDb) + .WithReferenceRelationship(catalogDbServer) .WithReferenceRelationship(searchIndex); // Identity branch, kept entirely healthy so there is a green path to compare the other two against. @@ -114,23 +114,16 @@ internal sealed class TestResource(string name) : Resource(name), IResourceWithE /// internal sealed class TestResourceLifecycle(ResourceNotificationService notificationService) : IDistributedApplicationEventingSubscriber { - public Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken) + public async Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken) { - foreach (var resource in @event.Model.Resources.OfType()) - { - Task.Run( - async () => - { - await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); - - await notificationService.PublishUpdateAsync( - resource, - state => state with { State = new("Running", "success") }); - }, - cancellationToken); - } - - return Task.CompletedTask; + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + + // Keep startup work owned by the lifecycle event so cancellation and publication failures are + // observed instead of escaping from fire-and-forget tasks after the AppHost has stopped. + await Task.WhenAll(@event.Model.Resources.OfType().Select(resource => + notificationService.PublishUpdateAsync( + resource, + state => state with { State = new("Running", "success") }))); } public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) diff --git a/playground/HealthModel/README.md b/playground/HealthModel/README.md new file mode 100644 index 00000000000..c2dc7f21eaa --- /dev/null +++ b/playground/HealthModel/README.md @@ -0,0 +1,158 @@ +# Health model playground + +A container-free sample for iterating on the dashboard's resource graph and health model. +The resources are simulated; the database servers, APIs, and gateways do not start real services. + +From the repository root, run: + +```powershell +dotnet run --project playground\HealthModel\HealthModelSandbox.AppHost\HealthModelSandbox.AppHost.csproj +``` + +Open the login URL printed by the AppHost, then select **Health**. +The AppHost uses the repository's dashboard so local UI changes are included. +Keep the terminal open while using the playground; stop it with **Ctrl+C**. + +## Topology and health + +Each resource has one incoming relationship. APIs reference their branch's database server; +the database declares that server as its parent. This avoids a database appearing under both +the API and a separate top-level server. + +```text +AppHost + storefront + checkout-api + orders-db-server + orders-db + payments-gateway Degraded + catalog-api + catalog-db-server + catalog-db + search-index Unhealthy + identity-api + identity-cache +``` + +All other resources report Healthy. Once startup completes, the graph should show: + +| Entity | Aggregate health | Cause | +|---|---|---| +| checkout-api | Degraded | payments-gateway | +| catalog-api | Unhealthy | search-index | +| identity-api | Healthy | Healthy dependencies | +| storefront / AppHost | Unhealthy | catalog-api | + +To try a different scenario, change a leaf's `HealthStatus` in `AppHost.cs` and restart. +Both **Health** and **Resources > Graph** derive relationships from the AppHost. Health no +longer invents Services/Infrastructure groups or automatically limits the impact of containers. + +## Health model lite + +The Health page follows the +[Azure Monitor graph and entity inspection workflow](https://learn.microsoft.com/azure/azure-monitor/health-models/analyze-health) +and a restricted version of its +[designer](https://learn.microsoft.com/azure/azure-monitor/health-models/designer). + +- **Graph** shows live, aggregated health on rectangular entity cards. Select a card to see + its own signals, dependencies, parents and the health it propagates. +- **Entities** presents the same entities as a searchable list. State-count buttons filter + the list or dim nonmatching cards without removing relationship context. +- **Designer** lets you move cards, edit their display names, impact, health objectives and + dependency rollup rules. Apply entity edits to the draft, then use **Save changes** to persist. +- **Arrange** restores a deterministic hierarchical layout. **Undo** and **Discard changes** + operate on the draft; live health updates do not reset the layout. +- Drag cards only in Designer, or focus a card and use arrow keys to move it (Shift moves + further). Positions are canvas coordinates, not screen pixels or zoom transforms. + +Saved settings are scoped to the application in this browser's local storage. They are +not automatically written into the AppHost project and are not shared across browsers. +Export the saved model to keep a project-owned copy. + +### Portable model definition + +**Export model** downloads `aspire-healthmodel.json`. Add that file to your AppHost project +when you want to retain the design alongside your code. **Import model** restores its +positions and propagation settings as an unsaved draft. Imports must match the current +AppHost's application name, resource bindings, entities, relationships and local signals. +Changing topology remains an AppHost operation, not a browser-only edit. + +The version 1 document contains: + +| Field | Purpose | +|---|---| +| `name` | Model identifier; also identifies the root entity | +| `applicationName` | Prevents applying another application's settings | +| `entities[].name` | Stable, Azure-compatible identity, independent of runtime suffixes | +| `aspireResourceName`, `replicaIndex` | Binding back to the corresponding AppHost resource | +| `canvasPosition` | Saved X/Y coordinates, preserved by export and import | +| `impact`, `dependencies`, `healthObjective` | Declarative health propagation settings | +| `localSignals` | Local signal names and kinds, without readings or exception details | +| `relationships` | Parent and child entity identities | + +No environment variables, credentials, endpoint addresses, observed health values, or +exception text are exported. Root identity, topology and positions are deliberately separate +from the live signal readings. + +### Publishing boundary + +The export is a **portable definition, not an ARM/Bicep deployment template**. This iteration +does not register an Azure publisher and does not create cloud resources. A future publisher +should consume the project-owned definition rather than reconstructing a different graph: +emit entities using its stable identities, emit the same relationships, and copy +`canvasPosition`, impact and dependency settings to the Azure model. + +Local lifecycle and health-check signals need explicit cloud equivalents (metrics, queries, +or an external signal producer). The publisher must also bind AppHost resources to deployed +ARM resource IDs and configure authentication. A matching picture alone does not establish +equivalent cloud health evaluation. + +The initial documented Bicep target is +[`Microsoft.CloudHealth/healthmodels@2026-05-01-preview`](https://learn.microsoft.com/azure/templates/microsoft.cloudhealth/2026-05-01-preview/healthmodels). +The health model is **a separate Azure resource**, not a workspace or a child of a workspace: + +| Resource | Purpose | +|---|---| +| `Microsoft.CloudHealth/healthmodels` | Owns entities, relationships and health configuration | +| `Microsoft.Monitor/accounts` | Optional Azure Monitor workspace for Prometheus/PromQL signals | +| `Microsoft.OperationalInsights/workspaces` | Optional Log Analytics workspace for KQL signals | + +A future publisher should create the appropriate signal sources rather than automatically +creating both workspace types. Azure resource metrics can reference the monitored resource directly. + +Important integration constraints: + +- Azure creates its root entity with the **model's name**. The publisher must keep that identity + consistent with the root referenced by the exported relationships. +- Relationship endpoints use entity resource names. Rewiring requires replacing the relationship, + not updating its endpoints in place. +- The local editor preserves X/Y values independently of zoom. The REST schema defines floating-point + coordinates while the generated Bicep reference presents integers, and the portal's coordinate + origin/anchor is not documented. Validate the conversion against Azure before claiming identical + positioning; do not silently round exported coordinates. +- `signalGroups.external` is read-only. Aspire health-check results require ongoing + [health-report ingestion](https://learn.microsoft.com/azure/azure-monitor/health-models/health-report-ingestion) + or an explicit metric/query equivalent. Bicep cannot provision a persistent external health result. +- The local threshold evaluator follows the inclusive comparisons in the pinned API schema. + Conceptual examples differ at equality, and some Unknown-state edge cases are underspecified. + Cloud execution parity still needs service-level validation. + +This preview does not include historical timelines, alert delivery, Azure discovery, +cloud metric/query execution or arbitrary browser-defined entities and relationships. +Cyclic AppHost references remain inspectable in **Resources > Graph**, but the Health +designer reports them as unsupported rather than silently dropping edges. Requiring an acyclic, +root-connected topology is a local lite-product restriction, not a claim that Azure prohibits +every other topology. + +## Resource graph controls + +- Drag a node to position and pin it. Physics pauses while dragging, allowing overlap; + after release, neighbours separate. Dropping onto an older pinned node releases that older pin. +- Double-click a pinned node to release it. +- Zoom with the wheel or the zoom buttons; drag the background to pan. +- **Reset** clears pins, restores the hierarchical layout, and fits all nodes and labels into view. +- Focus a resource's action cog and press **Enter** to use its menu with the keyboard; + **Escape** closes the menu and returns focus to the cog. + +Known health states keep their colour on hover and selection: solid relationship lines, +full-colour node outlines, and a 30% background tint. diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor index 62984dd03c1..5b66087706e 100644 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor @@ -3,9 +3,55 @@ @using Aspire.Dashboard.Resources @using Aspire.Dashboard.Utils -@inject IStringLocalizer Loc -
+ @if (Editable && Configuration is not null) + { +
+

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelDesignerTab)]

+ +
+ + +
+ + @foreach (var impact in Enum.GetValues()) + { + @HealthModelLabels.Impact(impact, Loc) + } + +

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelImpactHint)]

+ @if (Node.Children.Length > 0) + { + + @foreach (var aggregation in Enum.GetValues()) + { + @HealthModelLabels.Aggregation(aggregation, Loc) + } + + @if (_aggregation != nameof(DependenciesAggregationType.WorstOf)) + { + + @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelAbsolute)] + @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPercentage)] + + + + +

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelThresholdHint)]

+ } + } + +

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelObjectiveHint)]

+ @if (_validationError) + { + + } + @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelApply)] +
+ }
@@ -20,19 +66,19 @@ var (icon, color) = HealthModelIconHelpers.GetHealthStateIcon(Node.State); } - @Node.State.ToString() + @HealthModelLabels.State(Node.State, Loc)
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertySignalsState)] - @Node.SignalsState.ToString() + @HealthModelLabels.State(Node.SignalsState, Loc)
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyDependenciesState)] @if (Node.DependenciesState is { } dependenciesState) { - @dependenciesState.ToString() + @HealthModelLabels.State(dependenciesState, Loc) } else { @@ -42,12 +88,48 @@
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyImpact)] - @Node.Entity.Impact.ToString() + @HealthModelLabels.Impact(Node.Entity.Impact, Loc)
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyRollup)] @RollupDescription
+ @if (Node.Entity.Dependencies.AggregationType != DependenciesAggregationType.WorstOf) + { +
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelUnhealthyThreshold)] + @Node.Entity.Dependencies.UnhealthyThreshold?.ToString("0.##", CultureInfo.CurrentCulture) @ThresholdUnit +
+ @if (Node.Entity.Dependencies.DegradedThreshold is { } degraded) + { +
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelDegradedThreshold)] + @degraded.ToString("0.##", CultureInfo.CurrentCulture) @ThresholdUnit +
+ } + } + @if (Node.Entity.HealthObjective is { } objective) + { +
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelHealthObjective)] + @objective.ToString("0.##", CultureInfo.CurrentCulture) +
+ } + @if (Configuration is { } config) + { +
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPositionX)] / @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPositionY)] + @config.CanvasPosition.X.ToString("0.##", CultureInfo.CurrentCulture) / @config.CanvasPosition.Y.ToString("0.##", CultureInfo.CurrentCulture) +
+ } +
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelEntityId)] + @Node.Name +
+
+ @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropagation)] + @HealthModelLabels.State(Node.State.ApplyImpact(Node.Entity.Impact), Loc) +
@if (Node.Entity.ResourceName is { } resourceName) {
@@ -70,6 +152,7 @@ } else { +

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelSignalsSource)]

- @context.State.ToString() + @HealthModelLabels.State(context.State, Loc) - @context.DisplayName + @{ var (childIcon, childColor) = HealthModelIconHelpers.GetHealthStateIcon(context.State); } - @context.State.ToString() + @HealthModelLabels.State(context.State, Loc) - @context.Entity.Impact.ToString() + @HealthModelLabels.Impact(context.Entity.Impact, Loc) } -
- -@code { - [Parameter, EditorRequired] - public required HealthModelNode Node { get; set; } - - // See the note in HealthModel.razor.cs: FluentDataGrid cannot compose LINQ operators over a queryable - // built directly from an ImmutableArray, so the entries are materialized into lists first. - private IQueryable _signals = Enumerable.Empty().AsQueryable(); - private IQueryable _children = Enumerable.Empty().AsQueryable(); - - private string RollupDescription => Pages.HealthModel.GetRollupDescription(Node.Entity.Dependencies, Loc); - - protected override void OnParametersSet() + @if (Parents.Count > 0) { - _signals = Node.Entity.Signals.ToList().AsQueryable(); - _children = Node.Children.ToList().AsQueryable(); +
+

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelParents)]

+ @foreach (var parent in Parents) + { + + } +
} -} +
diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs new file mode 100644 index 00000000000..40df75ff4f2 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs @@ -0,0 +1,130 @@ +// 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.Model.HealthModel; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Strings = Aspire.Dashboard.Resources.HealthModel; + +namespace Aspire.Dashboard.Components.Controls; + +public partial class HealthModelEntityDetails : ComponentBase +{ + [Inject] + public required IStringLocalizer Loc { get; init; } + [Parameter, EditorRequired] + public required HealthModelNode Node { get; set; } + [Parameter] + public HealthModelSnapshot? Snapshot { get; set; } + [Parameter] + public HealthModelEntityConfiguration? Configuration { get; set; } + [Parameter] + public bool Editable { get; set; } + [Parameter] + public EventCallback OnApply { get; set; } + [Parameter] + public EventCallback OnSelect { get; set; } + + private IQueryable _signals = Enumerable.Empty().AsQueryable(); + private IQueryable _children = Enumerable.Empty().AsQueryable(); + private HealthModelEntityConfiguration? _previous; + private string _displayName = string.Empty; + private string _impact = nameof(EntityImpact.Standard); + private string _aggregation = nameof(DependenciesAggregationType.WorstOf); + private string _unit = nameof(AggregationUnit.Absolute); + private double _x; + private double _y; + private double? _objective; + private double? _degraded; + private double? _unhealthy; + private bool _ignoreUnknown = true; + private bool _validationError; + + private string RollupDescription => HealthModelLabels.Aggregation(Node.Entity.Dependencies.AggregationType, Loc); + private string ThresholdUnit => Loc[Node.Entity.Dependencies.Unit == AggregationUnit.Percentage + ? nameof(Strings.HealthModelPercentage) : nameof(Strings.HealthModelAbsolute)]; + private IReadOnlyList Parents => Snapshot is null ? [] : + Snapshot.AllNodes.Where(n => n.Children.Any(child => child.Name == Node.Name)).ToArray(); + + protected override void OnParametersSet() + { + _signals = Node.Entity.Signals.ToList().AsQueryable(); + _children = Node.Children.ToList().AsQueryable(); + if (Configuration is not { } config) + { + return; + } + // Health refreshes must not overwrite a partially edited form. Only reload when the selected + // entity or its declarative settings actually change (for example, Discard or Undo). + if (_previous is { } previous && + (previous.Name, previous.DisplayName, previous.CanvasPosition, previous.Impact, previous.Dependencies, previous.HealthObjective) == + (config.Name, config.DisplayName, config.CanvasPosition, config.Impact, config.Dependencies, config.HealthObjective)) + { + return; + } + _previous = config; + _displayName = config.DisplayName; + _x = config.CanvasPosition.X; + _y = config.CanvasPosition.Y; + _impact = config.Impact.ToString(); + _aggregation = config.Dependencies.AggregationType.ToString(); + _unit = config.Dependencies.Unit.ToString(); + _degraded = config.Dependencies.DegradedThreshold; + _unhealthy = config.Dependencies.UnhealthyThreshold; + _ignoreUnknown = config.Dependencies.IgnoreUnknown; + _objective = config.HealthObjective; + _validationError = false; + } + + private async Task ApplyAsync() + { + if (!Editable || Configuration is null) + { + return; + } + _validationError = false; + if (!Enum.TryParse(_impact, out var impact) || + !Enum.TryParse(_aggregation, out var aggregation) || + !Enum.TryParse(_unit, out var unit)) + { + _validationError = true; + return; + } + + var dependencies = aggregation == DependenciesAggregationType.WorstOf + ? DependenciesAggregation.WorstOf + : new DependenciesAggregation + { + AggregationType = aggregation, + Unit = unit, + DegradedThreshold = _degraded, + UnhealthyThreshold = _unhealthy, + IgnoreUnknown = _ignoreUnknown + }; + try + { + HealthModelDocuments.ValidateAggregation(dependencies); + } + catch (InvalidDataException) + { + _validationError = true; + return; + } + if (string.IsNullOrWhiteSpace(_displayName) || _displayName.Length > 260 || + !HealthModelDocuments.IsValidPosition(new HealthModelCanvasPosition(_x, _y)) || + _objective is { } objective && (!double.IsFinite(objective) || objective < 0 || objective > 100)) + { + _validationError = true; + return; + } + + await OnApply.InvokeAsync(Configuration with + { + DisplayName = _displayName.Trim(), + CanvasPosition = new HealthModelCanvasPosition(_x, _y), + Impact = Node.Entity.ResourceName is null ? EntityImpact.Standard : impact, + Dependencies = dependencies, + HealthObjective = _objective + }); + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css index 8b733654c12..5a5d5f1f758 100644 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css @@ -3,6 +3,15 @@ overflow: auto; } +.health-model-entity-editor { padding: 12px; display: flex; flex-direction: column; gap: 12px; border-bottom: 1px solid var(--neutral-stroke-divider-rest); } +.health-model-entity-editor h3 { margin: 0; } +.health-model-coordinate-fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; } +.health-model-editor-hint { font-size: 12px; color: var(--foreground-subtext-rest); margin: 0; white-space: normal; } +.health-model-settings-error { color: var(--error); } +.health-model-identity { overflow-wrap: anywhere; font-size: 11px; } +.health-model-parent-list { padding: 12px; display: flex; flex-direction: column; gap: 8px; } +::deep .health-model-related-entity { background: none; border: 0; color: var(--accent-foreground-rest); font: inherit; padding: 0; text-align: left; cursor: pointer; } + ::deep .health-model-property-list { display: flex; flex-direction: column; diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor new file mode 100644 index 00000000000..4ac8aed9807 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor @@ -0,0 +1,65 @@ +@using Aspire.Dashboard.Model.HealthModel +@using Strings = Aspire.Dashboard.Resources.HealthModel + +
+ + + + + + + + + + + + + @foreach (var relationship in Snapshot.Definition.Relationships) + { + var parent = _nodes[relationship.ParentEntityName]; + var child = _nodes[relationship.ChildEntityName]; + var state = child.State.ApplyImpact(child.Entity.Impact); + + @Loc[nameof(Strings.HealthModelRelationshipLabel), parent.DisplayName, child.DisplayName, HealthModelLabels.State(state, Loc)] + + } + + + @foreach (var node in Snapshot.AllNodes) + { + var position = _configuration[node.Name].CanvasPosition; + + @node.DisplayName + + + + @HealthModelLabels.State(node.State, Loc) + @Truncate(node.DisplayName) + @Truncate(node.Entity.Category ?? string.Empty) + @Loc[nameof(Strings.HealthModelSignalsHeader)]: @node.Entity.Signals.Length + @if (Editable) + { + + } + + } + + + +
+ + + +
+
diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs new file mode 100644 index 00000000000..a7191622524 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs @@ -0,0 +1,153 @@ +// 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.Model.HealthModel; +using Aspire.Dashboard.Utils; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.JSInterop; +using Strings = Aspire.Dashboard.Resources.HealthModel; + +namespace Aspire.Dashboard.Components.Controls; + +public sealed record HealthModelPositionChange(string Name, double X, double Y); + +public partial class HealthModelGraph : ComponentBase, IAsyncDisposable +{ + private readonly string _gridId = $"health-model-grid-{Guid.NewGuid():N}"; + private readonly string _arrowId = $"health-model-arrow-{Guid.NewGuid():N}"; + private ElementReference _svg; + private IJSObjectReference? _module; + private IJSObjectReference? _graph; + private DotNetObjectReference? _reference; + private bool _disposed; + private int _lastLayoutVersion = -1; + private Dictionary _nodes = new(StringComparer.Ordinal); + private Dictionary _configuration = new(StringComparer.Ordinal); + + [Inject] + public required IJSRuntime JS { get; init; } + [Inject] + public required IStringLocalizer Loc { get; init; } + [Parameter, EditorRequired] + public required HealthModelSnapshot Snapshot { get; set; } + [Parameter, EditorRequired] + public required HealthModelDocument Document { get; set; } + [Parameter] + public bool Editable { get; set; } + [Parameter] + public string? SelectedEntityName { get; set; } + [Parameter] + public string Filter { get; set; } = string.Empty; + [Parameter] + public HealthState? StateFilter { get; set; } + [Parameter] + public int LayoutVersion { get; set; } + [Parameter] + public EventCallback OnSelect { get; set; } + [Parameter] + public EventCallback OnPositionChanged { get; set; } + + protected override void OnParametersSet() + { + _nodes = Snapshot.AllNodes.ToDictionary(n => n.Name, StringComparer.Ordinal); + _configuration = Document.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_disposed) + { + return; + } + if (firstRender) + { + _module = await JS.InvokeAsync("import", "/js/app-healthmodel.js"); + if (_disposed) + { + await JSInteropHelpers.SafeDisposeAsync(_module); + return; + } + _reference = DotNetObjectReference.Create(this); + _graph = await _module.InvokeAsync("createHealthModelGraph", _svg, _reference); + } + if (_graph is not null) + { + await _graph.InvokeVoidAsync("update", + Document.Entities.Select(e => new { e.Name, e.CanvasPosition.X, e.CanvasPosition.Y }).ToArray(), + Editable); + if (_lastLayoutVersion != LayoutVersion) + { + _lastLayoutVersion = LayoutVersion; + await _graph.InvokeVoidAsync("fit"); + } + } + } + + [JSInvokable] + public Task MoveEntity(string name, double x, double y) => + !_disposed && Editable && _configuration.ContainsKey(name) + ? OnPositionChanged.InvokeAsync(new HealthModelPositionChange(name, x, y)) + : Task.CompletedTask; + + [JSInvokable] + public Task SelectEntity(string name) => + !_disposed && _configuration.ContainsKey(name) ? OnSelect.InvokeAsync(name) : Task.CompletedTask; + + public async Task FitAsync() + { + if (_graph is not null) + { + await _graph.InvokeVoidAsync("fit"); + } + } + + private async Task ZoomAsync(double factor) + { + if (_graph is not null) + { + await _graph.InvokeVoidAsync("zoomBy", factor); + } + } + + private string GetNodeClass(HealthModelNode node) + { + var matches = (StateFilter is null || node.State == StateFilter) && + (Filter.Length == 0 || node.DisplayName.Contains(Filter, StringComparisons.UserTextSearch)); + return $"health-model-entity{(SelectedEntityName == node.Name ? " is-selected" : "")}{(matches ? "" : " is-dimmed")}"; + } + + private static string Truncate(string value) => value.Length > 27 ? value[..24] + "..." : value; + private static string GetTransform(HealthModelCanvasPosition position) => + FormattableString.Invariant($"translate({position.X},{position.Y})"); + + private string GetPath(HealthModelRelationship relationship) + { + var parent = _configuration[relationship.ParentEntityName].CanvasPosition; + var child = _configuration[relationship.ChildEntityName].CanvasPosition; + var y1 = parent.Y + HealthModelLayout.CardHeight / 2; + var y2 = child.Y - HealthModelLayout.CardHeight / 2; + var middle = (y1 + y2) / 2; + return string.Create(CultureInfo.InvariantCulture, $"M {parent.X} {y1} C {parent.X} {middle}, {child.X} {middle}, {child.X} {y2}"); + } + + public async ValueTask DisposeAsync() + { + _disposed = true; + if (_graph is not null) + { + try + { + await _graph.InvokeVoidAsync("dispose"); + } + catch (JSDisconnectedException) + { + // The browser already discarded the graph when the circuit disconnected. + } + await JSInteropHelpers.SafeDisposeAsync(_graph); + } + _reference?.Dispose(); + await JSInteropHelpers.SafeDisposeAsync(_module); + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css new file mode 100644 index 00000000000..ce94d50a7b3 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css @@ -0,0 +1,53 @@ +.health-model-canvas { + position: relative; + width: 100%; + height: 100%; + min-height: 360px; + overflow: hidden; + background: var(--fill-color); +} + +.health-model-graph { + display: block; + width: 100%; + height: 100%; + min-height: 360px; + touch-action: none; +} + +.health-model-grid-dot { fill: var(--neutral-stroke-rest); opacity: 0.5; } +.health-model-edge, .health-model-entity { --health-colour: var(--info); } +[data-health="Healthy"] { --health-colour: var(--aspire-status-success); } +[data-health="Degraded"] { --health-colour: var(--aspire-status-warning); } +[data-health="Unhealthy"] { --health-colour: var(--aspire-status-error); } + +.health-model-edge { + stroke: var(--health-colour); + fill: none; + stroke-width: 2; +} + +.health-model-card { + stroke: var(--health-colour); + stroke-width: 2; + fill: color-mix(in srgb, var(--health-colour) 30%, var(--fill-color)); +} + +.health-model-selection { + fill: none; + stroke: transparent; + stroke-width: 2; +} + +.health-model-entity { cursor: pointer; outline: none; } +[data-editable="true"] .health-model-entity { cursor: grab; } +.health-model-entity.is-selected .health-model-selection, +.health-model-entity:focus-visible .health-model-selection { stroke: var(--focus-stroke-outer); } +.health-model-entity.is-dimmed { opacity: 0.35; } +.health-model-state-dot { fill: var(--health-colour); } +.health-model-connector { fill: var(--fill-color); stroke: var(--health-colour); stroke-width: 2; } +.health-model-card-name { font-size: 14px; font-weight: 600; fill: var(--neutral-foreground-rest); } +.health-model-card-state { font-size: 12px; fill: var(--neutral-foreground-rest); } +.health-model-card-type, .health-model-card-signals { font-size: 11px; fill: var(--neutral-foreground-rest); } +.health-model-entity text { pointer-events: none; user-select: none; } +.health-model-canvas-actions { position: absolute; bottom: 12px; right: 12px; display: flex; background: var(--fill-color); border-radius: 4px; } diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor index b258bcdbe69..3573e5ea9cf 100644 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor @@ -1,117 +1,137 @@ @page "/healthmodel" - @using Aspire.Dashboard.Components.Controls.Grid @using Aspire.Dashboard.Model.HealthModel @using Aspire.Dashboard.Resources -@using Aspire.Dashboard.Utils +@using Strings = Aspire.Dashboard.Resources.HealthModel -@inject IStringLocalizer Loc +@inject IStringLocalizer Loc @inject IStringLocalizer ControlsStringsLoc -@implements IAsyncDisposable - - - - +
- + -

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelHeader)]

+

@Loc[nameof(Strings.HealthModelHeader)]

- - - -
-
- @{ - var (overallIcon, overallColor) = HealthModelIconHelpers.GetHealthStateIcon(_snapshot.State); - } - - @_snapshot.State.ToString() - - @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelDescription)] - -
- -
- - - - @{ - var (icon, color) = HealthModelIconHelpers.GetHealthStateIcon(context.State); - } - - - @context.DisplayName - - - - - @context.Entity.Category - - - - @context.State.ToString() - - - - @if (context.Entity.Signals.Length > 0) - { - @GetSignalSummary(context) - } - else - { - - } - - - - @if (context.Children.Length > 0) - { - @GetRollupDescription(context) - } - else - { - - } - - - -  @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelNoEntities)] - - +
+
+
+ @_applicationName + @Loc[nameof(Strings.HealthModelLocalPreview)] + @Loc[_dirty ? nameof(Strings.HealthModelUnsaved) : _savedInBrowser ? nameof(Strings.HealthModelSaved) : nameof(Strings.HealthModelDefaultLayout)] +
+
+ + @Loc[nameof(Strings.HealthModelExport)] + + +
+
+
+ + + + + + @if (_view == HealthModelView.Designer) + { +
+ + @Loc[nameof(Strings.HealthModelSave)] + + @Loc[nameof(Strings.HealthModelDiscard)] + @Loc[nameof(Strings.HealthModelUndo)] + @Loc[nameof(Strings.HealthModelArrange)]
+ } +
+
+ @{ + var (icon, colour) = HealthModelIconHelpers.GetHealthStateIcon(_snapshot.State); + } + @StateLabel(_snapshot.State) + @Loc[nameof(Strings.HealthModelEntityCount), _snapshot.AllNodes.Length, _snapshot.Definition.Relationships.Length] +
+ @foreach (var state in new[] { HealthState.Healthy, HealthState.Degraded, HealthState.Unhealthy, HealthState.Unknown }) + { + + }
-
-
- -
-
+ +
+

@Loc[IsDesigner ? nameof(Strings.HealthModelDesignerHint) : nameof(Strings.HealthModelGraphHint)]

+ @if (_message is not null) + { +
@_message
+ } + @if (DashboardClient.IsReadOnly) + { +
@Loc[nameof(Strings.HealthModelReadOnly)]
+ } +
+ @if (_draft is not null && !_invalidTopology) + { + + + @if (_view == HealthModelView.Entities) + { +
+ + + + + + @context.Entity.Category + @StateLabel(context.State) + @SignalSummary(context) + + @Loc[nameof(Strings.HealthModelNoMatch)] + +
+ } + else + { + + } +
+
+ +
+
+ } +
+
+ @Loc[nameof(Strings.HealthModelLocalPreview)] +

@Loc[nameof(Strings.HealthModelCloudBoundary)]

+

@Loc[nameof(Strings.HealthModelDefinitionHint)]

+
+
diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs index 146f177c49f..c53bee20add 100644 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs @@ -1,177 +1,452 @@ // 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 System.Text.Json; +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.HealthModel; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Localization; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; +using Strings = Aspire.Dashboard.Resources.HealthModel; namespace Aspire.Dashboard.Components.Pages; public partial class HealthModel : ComponentBase, IAsyncDisposable { - /// The left padding, in pixels, applied per level of model depth in the entity column. - private const int IndentPerDepth = 16; - private readonly CancellationTokenSource _cts = new(); - private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); - - private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default; - private ColumnSortLabels _sortLabels = ColumnSortLabels.Default; + private readonly Dictionary _resources = new(StringComparers.ResourceName); + private readonly Stack _undo = new(); + private HealthModelDefinition _live = new() { Name = AspireHealthModelBuilder.RootEntityName }; private HealthModelSnapshot _snapshot = HealthModelSnapshot.Empty; - - // FluentDataGrid composes LINQ operators such as Count() onto the queryable it is given. An - // ImmutableArray is a struct, so a queryable built directly over one produces an expression tree - // typed as ImmutableArray that those operators reject at runtime. Materializing into a list first - // keeps the expression typed as a reference type, and caching it avoids rebuilding on every render. - private IQueryable _nodes = Enumerable.Empty().AsQueryable(); - + private HealthModelDocument? _saved; + private HealthModelDocument? _draft; private HealthModelNode? _selectedNode; - private Task? _resourceSubscriptionTask; + private HealthModelGraph? _graph; + private Task? _subscriptionTask; + private string _applicationName = string.Empty; + private string _filter = string.Empty; + private string? _message; + private bool _isError; + private bool _invalidTopology; + private bool _dirty; + private bool _savedInBrowser; + private bool _saving; + private bool _disposed; + private int _layoutVersion; + private HealthState? _stateFilter; + private HealthModelView _view = HealthModelView.Graph; [Inject] public required DashboardDataSource DataSource { get; init; } - + [Inject] + public required IDashboardClient DashboardClient { get; init; } [Inject] public required NavigationManager NavigationManager { get; init; } + [Inject] + public required ILocalStorage LocalStorage { get; init; } + [Inject] + public required IJSRuntime JS { get; init; } + [Inject] + public required ILogger Logger { get; init; } [Parameter] [SupplyParameterFromQuery(Name = "entity")] public string? EntityName { get; set; } - - protected override void OnInitialized() - { - (_resizeLabels, _sortLabels) = DashboardUIHelpers.CreateGridLabels(ControlsStringsLoc); - } + [Parameter] + [SupplyParameterFromQuery(Name = "view")] + public string? ViewName { get; set; } + + private bool CanEdit => !DashboardClient.IsReadOnly && !_invalidTopology && !_saving; + private bool IsDesigner => _view == HealthModelView.Designer && CanEdit; + private string StorageKey => $"Aspire_HealthModel_v1_{Uri.EscapeDataString(_applicationName)}"; + private IQueryable FilteredNodes => _snapshot.AllNodes + .Where(n => (_stateFilter is null || n.State == _stateFilter) && + (_filter.Length == 0 || n.DisplayName.Contains(_filter, StringComparisons.UserTextSearch))) + .ToList().AsQueryable(); + private HealthModelEntityConfiguration? SelectedConfiguration => _draft?.Entities.FirstOrDefault(e => e.Name == _selectedNode?.Name); protected override async Task OnInitializedAsync() { - var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_cts.Token); + var cancellationToken = _cts.Token; + if (DashboardClient.IsEnabled) + { + await DashboardClient.WhenConnected.WaitAsync(cancellationToken); + } + if (_disposed) + { + return; + } + _applicationName = DashboardClient.ApplicationName; + var stored = await LocalStorage.GetAsync(StorageKey); + if (_disposed) + { + return; + } + if (stored.Success && stored.Value is { } document) + { + try + { + HealthModelDocuments.Validate(document, _applicationName); + _saved = document; + _savedInBrowser = true; + } + catch (InvalidDataException ex) + { + Logger.LogWarning(ex, "The saved health model is invalid."); + ShowMessage(nameof(Strings.HealthModelLoadError), error: true); + } + } + var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken); + if (_disposed) + { + return; + } foreach (var resource in snapshot) { - _resourceByName[resource.Name] = resource; + _resources[resource.Name] = resource; } - RebuildModel(); + _subscriptionTask = WatchAsync(subscription); + } - _resourceSubscriptionTask = Task.Run(async () => + private async Task WatchAsync(IAsyncEnumerable> subscription) + { + try { await foreach (var changes in subscription.WithCancellation(_cts.Token).ConfigureAwait(false)) { - foreach (var (changeType, resource) in changes) + // All model and draft mutations are serialized on the renderer, including subscription + // updates that arrive while the user is dragging or saving. + await InvokeAsync(() => { - if (changeType == ResourceViewModelChangeType.Upsert) + if (_disposed) { - _resourceByName[resource.Name] = resource; + return; } - else if (changeType == ResourceViewModelChangeType.Delete) + foreach (var (changeType, resource) in changes) { - _resourceByName.TryRemove(resource.Name, out _); + if (changeType == ResourceViewModelChangeType.Upsert) + { + _resources[resource.Name] = resource; + } + else if (changeType == ResourceViewModelChangeType.Delete) + { + _resources.Remove(resource.Name); + } } - } - + RebuildModel(); + StateHasChanged(); + }); + } + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Health model resource subscription failed."); + if (!_disposed) + { await InvokeAsync(() => { - RebuildModel(); + ShowMessage(nameof(Strings.HealthModelSubscriptionError), error: true); StateHasChanged(); }); } - }); + } } protected override void OnParametersSet() { - // The selected entity is carried in the query string so the details pane survives a page reload and - // follows browser navigation. Clearing when the parameter is absent is what closes the pane when the - // user navigates back to the page without a selection. - if (EntityName is null) + if (Enum.TryParse(ViewName, ignoreCase: true, out var view) && Enum.IsDefined(view)) { - _selectedNode = null; + _view = view; } - else if (_selectedNode?.Name != EntityName) + else { - _selectedNode = _snapshot.AllNodes.FirstOrDefault(n => string.Equals(n.Name, EntityName, StringComparison.Ordinal)); + _view = HealthModelView.Graph; } + ResolveSelection(); } private void RebuildModel() { - var definition = AspireHealthModelBuilder.Build(_resourceByName.Values); - _snapshot = HealthModelEvaluator.Evaluate(definition); - _nodes = _snapshot.AllNodes.ToList().AsQueryable(); + _live = AspireHealthModelBuilder.Build(_resources.Values); + try + { + var defaults = HealthModelDocuments.Create(_live, _applicationName); + _saved ??= defaults; + if (!_dirty) + { + // AppHost discovery can initially return only the root. Keep the full saved document + // as the baseline so late-arriving resources recover their saved coordinates. + _draft = _savedInBrowser ? HealthModelDocuments.Reconcile(_saved, _live) : defaults; + if (!_savedInBrowser) + { + _saved = defaults; + } + } + else + { + var baseline = _draft! with + { + Entities = [.. _saved.Entities.Concat(_draft!.Entities).GroupBy(e => e.Name, StringComparer.Ordinal).Select(g => g.Last())] + }; + _draft = HealthModelDocuments.Reconcile(baseline, _live); + } + EvaluateDraft(); + _invalidTopology = false; + } + catch (InvalidDataException ex) + { + Logger.LogWarning(ex, "The AppHost topology cannot be represented as a health model."); + _invalidTopology = true; + ShowMessage(nameof(Strings.HealthModelInvalidTopology), error: true); + } + } + + private void EvaluateDraft() + { + _snapshot = HealthModelEvaluator.Evaluate(_draft is null ? _live : HealthModelDocuments.Apply(_live, _draft)); + ResolveSelection(); + } - // Entities are rebuilt from scratch on every resource change, so the previously selected node is a - // stale instance. Re-resolve it by name to keep the details pane pointing at live data. - if (_selectedNode is not null) + private void ResolveSelection() + { + _selectedNode = EntityName is null ? null : _snapshot.AllNodes.FirstOrDefault(n => + n.Name == EntityName || n.Entity.ResourceKey == EntityName); + } + + private void ChangeView(FluentTab tab) + { + if (Enum.TryParse(tab.Id, out var view)) { - _selectedNode = _snapshot.AllNodes.FirstOrDefault(n => string.Equals(n.Name, _selectedNode.Name, StringComparison.Ordinal)); + _view = view; + Navigate(); } } - private void SelectEntity(HealthModelNode node) + private void SelectEntity(string name) { - _selectedNode = node; - NavigationManager.NavigateTo(DashboardUrls.HealthModelUrl(node.Name), replace: true); + EntityName = name; + ResolveSelection(); + Navigate(); } private void ClearSelectedEntity() { + EntityName = null; _selectedNode = null; - NavigationManager.NavigateTo(DashboardUrls.HealthModelUrl(), replace: true); + Navigate(); } - private static string GetIndentStyle(HealthModelNode node) - => $"padding-left: {node.Depth * IndentPerDepth}px;"; + private void Navigate() => NavigationManager.NavigateTo( + DashboardUrls.HealthModelUrl(EntityName, _view.ToString()), replace: true); - private string GetSignalSummary(HealthModelNode node) + private void ToggleState(HealthState state) => _stateFilter = _stateFilter == state ? null : state; + + private void ChangeDraft(HealthModelDocument document) { - var healthy = node.Entity.Signals.Count(s => s.State == HealthState.Healthy); + if (!CanEdit || _draft is null) + { + return; + } + // A bounded undo stack stores only declarative configuration, never live resource data. + if (_undo.Count >= 20) + { + var recent = _undo.Take(19).Reverse().ToArray(); + _undo.Clear(); + foreach (var item in recent) + { + _undo.Push(item); + } + } + _undo.Push(_draft); + _draft = document; + _dirty = HealthModelDocuments.Serialize(_draft) != HealthModelDocuments.Serialize(HealthModelDocuments.Reconcile(_saved!, _live)); + _message = null; + EvaluateDraft(); + } - return string.Format( - CultureInfo.CurrentCulture, - Loc[nameof(Dashboard.Resources.HealthModel.HealthModelSignalCount)], - healthy, - node.Entity.Signals.Length); + private Task MoveEntity(HealthModelPositionChange change) + { + if (IsDesigner && _draft is not null && _draft.Entities.Any(e => e.Name == change.Name)) + { + try + { + var position = HealthModelLayout.Place(_draft, change.Name, new HealthModelCanvasPosition(change.X, change.Y)); + ChangeDraft(_draft with + { + Entities = [.. _draft.Entities.Select(e => e.Name == change.Name ? e with { CanvasPosition = position } : e)] + }); + } + catch (InvalidDataException ex) + { + Logger.LogWarning(ex, "Invalid position for health model entity '{EntityName}'.", change.Name); + ShowMessage(nameof(Strings.HealthModelInvalidSettings), error: true); + } + } + return Task.CompletedTask; + } + + private void ApplyEntity(HealthModelEntityConfiguration configuration) + { + if (!IsDesigner || _draft is null) + { + return; + } + try + { + var position = HealthModelLayout.Place(_draft, configuration.Name, configuration.CanvasPosition); + var updated = _draft with + { + Entities = [.. _draft.Entities.Select(e => e.Name == configuration.Name ? configuration with { CanvasPosition = position } : e)] + }; + HealthModelDocuments.Validate(updated, _applicationName); + ChangeDraft(updated); + } + catch (InvalidDataException ex) + { + Logger.LogWarning(ex, "Invalid health model entity configuration."); + ShowMessage(nameof(Strings.HealthModelInvalidSettings), error: true); + } + } + + private void Arrange() + { + if (!IsDesigner || _draft is null) + { + return; + } + var positions = HealthModelLayout.Arrange(_live); + ChangeDraft(_draft with { Entities = [.. _draft.Entities.Select(e => e with { CanvasPosition = positions[e.Name] })] }); + _layoutVersion++; + } + + private void Undo() + { + if (!IsDesigner || !_undo.TryPop(out var document)) + { + return; + } + _draft = HealthModelDocuments.Reconcile(document, _live); + _dirty = HealthModelDocuments.Serialize(_draft) != HealthModelDocuments.Serialize(HealthModelDocuments.Reconcile(_saved!, _live)); + EvaluateDraft(); } - private string GetRollupDescription(HealthModelNode node) => GetRollupDescription(node.Entity.Dependencies, Loc); + private void Discard() + { + if (!CanEdit || _saved is null) + { + return; + } + _draft = HealthModelDocuments.Reconcile(_saved, _live); + _dirty = false; + _undo.Clear(); + _message = null; + _layoutVersion++; + EvaluateDraft(); + } - /// - /// Describes a dependency rollup in the terms the Azure portal uses, so the configured aggregation is - /// readable without having to know the enum values. - /// - internal static string GetRollupDescription(DependenciesAggregation aggregation, IStringLocalizer loc) + private async Task SaveAsync() { - if (aggregation.AggregationType == DependenciesAggregationType.WorstOf) + if (!CanEdit || _draft is null) { - return loc[nameof(Dashboard.Resources.HealthModel.HealthModelRollupWorstOf)]; + return; } + _saving = true; + var saving = _draft; + try + { + HealthModelDocuments.Validate(saving, _applicationName); + await LocalStorage.SetAsync(StorageKey, saving); + _saved = saving; + _savedInBrowser = true; + _dirty = false; + _undo.Clear(); + ShowMessage(nameof(Strings.HealthModelSaveSuccess), error: false); + } + catch (Exception ex) when (ex is JSException or InvalidDataException or JsonException) + { + Logger.LogError(ex, "Failed to save the health model."); + ShowMessage(nameof(Strings.HealthModelSaveError), error: true); + } + finally + { + _saving = false; + } + } - var threshold = aggregation.UnhealthyThreshold ?? aggregation.DegradedThreshold ?? 0; - var formattedThreshold = aggregation.Unit == AggregationUnit.Percentage - ? threshold.ToString("0.##", CultureInfo.CurrentCulture) + "%" - : threshold.ToString("0.##", CultureInfo.CurrentCulture); + private async Task ExportAsync() + { + if (_saved is null || _dirty || _invalidTopology) + { + return; + } + try + { + var document = HealthModelDocuments.Reconcile(_saved, _live); + HealthModelDocuments.Validate(document, _applicationName); + await JS.DownloadFileAsync("aspire-healthmodel.json", HealthModelDocuments.Serialize(document)); + } + catch (Exception ex) when (ex is JSException or InvalidDataException) + { + Logger.LogError(ex, "Failed to export the health model."); + ShowMessage(nameof(Strings.HealthModelExportError), error: true); + } + } - var format = aggregation.AggregationType == DependenciesAggregationType.MinHealthy - ? loc[nameof(Dashboard.Resources.HealthModel.HealthModelRollupMinHealthy)] - : loc[nameof(Dashboard.Resources.HealthModel.HealthModelRollupMaxNotHealthy)]; + private async Task ImportAsync(InputFileChangeEventArgs args) + { + if (!CanEdit || _draft is null) + { + return; + } + try + { + await using var stream = args.File.OpenReadStream(HealthModelDocuments.MaxFileSize, _cts.Token); + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(_cts.Token); + var document = HealthModelDocuments.Deserialize(json, _draft); + ChangeDraft(document); + _view = HealthModelView.Designer; + _layoutVersion++; + Navigate(); + ShowMessage(nameof(Strings.HealthModelImportSuccess), error: false); + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException) + { + Logger.LogWarning(ex, "Failed to import the health model."); + ShowMessage(nameof(Strings.HealthModelImportError), error: true); + } + } - return string.Format(CultureInfo.CurrentCulture, format, formattedThreshold); + private void ShowMessage(string key, bool error) + { + _message = Loc[key]; + _isError = error; } + private string StateLabel(HealthState state) => HealthModelLabels.State(state, Loc); + private string SignalSummary(HealthModelNode node) => Loc[nameof(Strings.HealthModelSignalCount), + node.Entity.Signals.Count(s => s.State == HealthState.Healthy), node.Entity.Signals.Length]; + + internal static string GetRollupDescription(DependenciesAggregation aggregation, IStringLocalizer loc) => + HealthModelLabels.Aggregation(aggregation.AggregationType, loc); + public async ValueTask DisposeAsync() { + _disposed = true; await _cts.CancelAsync(); - - // Wait for the subscription loop to unwind before disposing the source. Disposing it first would - // make the loop throw ObjectDisposedException while observing the token. - await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask); - + await TaskHelpers.WaitIgnoreCancelAsync(_subscriptionTask); _cts.Dispose(); } + + private enum HealthModelView { Graph, Entities, Designer } } diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css index d118fb84224..88676f4e1a0 100644 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css @@ -1,42 +1,43 @@ -::deep table.main-grid { - margin-bottom: 1px !important; /* make bottom table row border visible when scrolling */ -} - -::deep .health-model-summary-layout { - display: grid; - grid-template-rows: auto minmax(0, 1fr); +::deep .health-model-layout { + display: flex; + flex-direction: column; height: 100%; - width: 100%; - grid-template-areas: - "overview" - "main"; + min-height: 500px; + overflow: hidden; } -::deep .health-model-overview { - grid-area: overview; +.health-model-header, .health-model-tabs-row, .health-model-overview { display: flex; align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: 12px; padding: 8px 16px; - border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); + border-bottom: 1px solid var(--neutral-stroke-divider-rest); } -::deep .health-model-overview-state { - font-weight: 600; -} +.health-model-header, .health-model-tabs-row { justify-content: space-between; } +.health-model-title, .health-model-file-actions, .health-model-design-actions, .health-model-state-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.health-model-save-state, .health-model-count { color: var(--foreground-subtext-rest); font-size: 12px; } +.health-model-overview-state { display: inline-flex; align-items: center; gap: 4px; font-weight: 600; } +.health-model-state-filter { display: flex; align-items: center; gap: 6px; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--neutral-foreground-rest); padding: 5px 8px; cursor: pointer; } +.health-model-state-filter[aria-pressed="true"] { border-color: var(--accent-fill-rest); background: var(--neutral-fill-secondary-rest); } +.health-model-dot { width: 8px; height: 8px; background: var(--info); border-radius: 50%; } +[data-health="Healthy"] .health-model-dot { background: var(--aspire-status-success); } +[data-health="Degraded"] .health-model-dot { background: var(--aspire-status-warning); } +[data-health="Unhealthy"] .health-model-dot { background: var(--aspire-status-error); } +.health-model-hint { margin: 0; padding: 8px 16px; color: var(--foreground-subtext-rest); font-size: 12px; } +.health-model-main { flex: 1; min-height: 0; } +::deep .health-model-grid-container { height: 100%; overflow: auto; } +::deep .health-model-entity-link { background: none; border: 0; color: var(--accent-foreground-rest); cursor: pointer; text-align: left; padding: 0; font: inherit; } +.health-model-message { padding: 8px 16px; background: var(--neutral-fill-secondary-rest); } +.health-model-message.is-error { border-left: 3px solid var(--aspire-status-error); } +.health-model-scope { padding: 8px 16px; border-top: 1px solid var(--neutral-stroke-divider-rest); color: var(--foreground-subtext-rest); font-size: 12px; } +.health-model-scope summary { cursor: pointer; } +.health-model-import { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; } +::deep .health-model-import input { max-width: 200px; } -::deep .health-model-overview-description { - color: var(--neutral-foreground-hint); -} - -::deep .health-model-grid-container { - grid-area: main; - overflow: auto; -} - -::deep .health-model-entity-name { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; +@media (max-width: 600px) { + .health-model-layout { min-height: 750px; } + .health-model-main { min-height: 380px; } + .health-model-file-actions { width: 100%; } } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index 4f5c61ba3b5..8b3d72b556f 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -270,9 +270,9 @@ visible trigger. Point it at a hidden, non-focusable element kept out of the accessibility tree so the stamped aria-expanded doesn't land on the role-less visible summary
(axe aria-allowed-attr) and AT users don't meet a non-operable control. *@ - + - +
? _resourcesInteropReference; private IJSObjectReference? _jsModule; private bool _graphInitialized; + private readonly string _graphInstanceId = Guid.NewGuid().ToString("N"); + private bool _disposed; + private string? _pendingContextMenuFocusItemId; private AspirePageContentLayout? _contentLayout; private TotalItemsFooter _totalItemsFooter = default!; private int _totalItemsCount; @@ -383,12 +387,28 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await JS.InvokeVoidAsync("focusElement", pendingFocusElementId); } - if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph && !_graphInitialized) + if (!_disposed && _pendingContextMenuFocusItemId is { } itemId && _jsModule is not null) + { + _pendingContextMenuFocusItemId = null; + var focused = await _jsModule.InvokeAsync("focusResourceMenuItem", _graphInstanceId, itemId, ContextMenuAnchorId); + if (!focused && !_disposed && _contextMenuOpen) + { + Logger.LogWarning("Unable to focus the resource context menu item '{MenuItemId}'.", itemId); + } + } + + if (!_disposed && PageViewModel.SelectedViewKind == ResourceViewKind.Graph && !_graphInitialized) { // Before any awaits, set a flag to indicate the graph is initialized. This prevents the graph being initialized multiple times. _graphInitialized = true; _jsModule = await JS.InvokeAsync("import", "/js/app-resourcegraph.js"); + if (_disposed) + { + await JSInteropHelpers.SafeDisposeAsync(_jsModule); + _jsModule = null; + return; + } _resourcesInteropReference = DotNetObjectReference.Create(new ResourcesInterop(this)); @@ -403,7 +423,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } }; - await _jsModule.InvokeVoidAsync("initializeResourcesGraph", _resourcesInteropReference, graphIcons); + await _jsModule.InvokeVoidAsync("initializeResourcesGraph", _resourcesInteropReference, graphIcons, _graphInstanceId); await UpdateResourceGraphResourcesAsync(); await UpdateResourceGraphSelectedAsync(); } @@ -411,7 +431,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private async Task UpdateResourceGraphResourcesAsync() { - if (PageViewModel.SelectedViewKind != ResourceViewKind.Graph || _jsModule == null) + if (_disposed || PageViewModel.SelectedViewKind != ResourceViewKind.Graph || _jsModule is null) { return; } @@ -664,6 +684,14 @@ private async Task ShowContextMenuAsync(ResourceViewModel resource, int screenWi _contextMenuClosedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (focusElementId is not null) + { + // Cursor-positioned menus use a hidden anchor, so Fluent cannot transfer keyboard + // focus from the graph cog. Focus an actionable item after the menu has rendered. + _pendingContextMenuFocusItemId = _contextMenuItems + .FirstOrDefault(item => !item.IsHeader && !item.IsDivider && !item.IsDisabled)?.Id; + } + await contextMenu.OpenAsync(screenWidth, screenHeight, clientX, clientY); StateHasChanged(); @@ -914,7 +942,7 @@ internal static ResourceViewKind GetVisibleViewKindForViewChange(ResourceViewKin private async Task UpdateResourceGraphSelectedAsync() { - if (_jsModule != null) + if (!_disposed && _jsModule is not null) { await _jsModule.InvokeVoidAsync("updateResourcesGraphSelected", PageViewModel.SelectedResource?.Name); } @@ -1008,15 +1036,33 @@ public ResourcesPageState ConvertViewModelToSerializable() public async ValueTask DisposeAsync() { + _disposed = true; CompleteContextMenuClosed(); - _resourcesInteropReference?.Dispose(); _cts.Cancel(); _logsSubscription?.Dispose(); - TelemetryContext.Dispose(); - await JSInteropHelpers.SafeDisposeAsync(_jsModule); - - await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask); + try + { + await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask); + if (_jsModule is not null) + { + try + { + await _jsModule.InvokeVoidAsync("disposeResourcesGraph", _graphInstanceId); + } + catch (JSDisconnectedException) + { + // There is no browser-side graph to release once the circuit has disconnected. + } + } + } + finally + { + _resourcesInteropReference?.Dispose(); + TelemetryContext.Dispose(); + _cts.Dispose(); + await JSInteropHelpers.SafeDisposeAsync(_jsModule); + } } private async Task ContextMenuClosedAsync(Microsoft.AspNetCore.Components.Web.MouseEventArgs args) @@ -1038,6 +1084,7 @@ private async Task ContextMenuOpenChangedAsync(bool open) private async Task CloseContextMenuAsync(bool closeMenu) { _contextMenuOpen = false; + _pendingContextMenuFocusItemId = null; var focusElementId = _contextMenuFocusElementId; _contextMenuFocusElementId = null; diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css index d2f3df478e3..1ad7e0bddfd 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css @@ -208,7 +208,7 @@ } ::deep .resource-group-hover .resource-node { - fill: var(--neutral-fill-hover) !important; + fill: var(--neutral-fill-hover); } ::deep .resource-group-selected .resource-scale { @@ -216,7 +216,7 @@ } ::deep .resource-group-selected .resource-node { - fill: var(--neutral-fill-secondary-rest) !important; + fill: var(--neutral-fill-secondary-rest); } ::deep .resource-group-selected .resource-node-border { @@ -348,18 +348,17 @@ fill: context-stroke; } -/* Health colouring deliberately outranks the neutral highlight stroke so hovering a link never hides the - health signal. The dash pattern and width still communicate the highlight. */ +/* Highlighting must not replace the health colour or turn a health path into a dashed line. */ ::deep .resource-link-highlight { stroke: var(--neutral-stroke-hover); - stroke-dasharray: 5,5; + stroke-dasharray: none; stroke-width: 2; marker-end: url(#arrow-highlight); } ::deep .resource-link-highlight-expand { stroke: var(--neutral-stroke-hover); - stroke-dasharray: 5,5; + stroke-dasharray: none; stroke-width: 2; marker-end: url(#arrow-highlight-expand); } diff --git a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs index bc6c648938f..dd097d79323 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs @@ -2,6 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Globalization; +using System.IO.Hashing; +using System.Text; +using Aspire.Dashboard.Model.ResourceGraph; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Dashboard.Model.HealthModel; @@ -10,37 +14,19 @@ namespace Aspire.Dashboard.Model.HealthModel; /// Projects the live Aspire application model into a . /// /// -/// -/// This is the sample model for the MVP. It is deliberately small and declarative so it is easy to change -/// while the shape of the feature settles. It builds two logical entities under the application root: -/// -/// -/// aspire-app-health (root, worst-of rollup) -/// |- services (projects and executables, standard impact) -/// |- infrastructure (containers, limited impact, threshold rollup) -/// -/// -/// The two logical entities exist to exercise the parts of the Azure model that are not obvious: a broken -/// service fails the application outright, while a broken container makes the infrastructure group unhealthy -/// and limited impact rewrites that to degraded by the time it reaches the application. -/// +/// Uses the same AppHost relationships as the resource graph. All entities have standard impact and +/// worst-of rollup initially; the designer can explicitly change those settings. /// public static class AspireHealthModelBuilder { /// The name of the health model, which is also the name of its root entity. public const string RootEntityName = "aspire-app-health"; - /// The logical entity that groups projects and executables. - public const string ServicesEntityName = "services"; - - /// The logical entity that groups containers. - public const string InfrastructureEntityName = "infrastructure"; - /// The name of the signal projected from a resource's lifecycle state. public const string ResourceStateSignalName = "resource-state"; /// - /// Builds the sample model from a set of resources. + /// Builds the dependency model from a set of resources. /// /// The resources currently known to the dashboard. public static HealthModelDefinition Build(IEnumerable resources) @@ -49,63 +35,34 @@ public static HealthModelDefinition Build(IEnumerable resourc var entities = ImmutableArray.CreateBuilder(); var relationships = ImmutableArray.CreateBuilder(); + var modelResources = resources + .Where(r => !r.IsResourceHidden(showHiddenResources: false)) + .Where(r => r.ResourceType is not (KnownResourceTypes.Parameter or KnownResourceTypes.ConnectionString)) + .OrderBy(r => r.PersistentKey, StringComparers.ResourceName) + .ToArray(); + var entityNames = modelResources.ToDictionary(r => r.Name, GetEntityName, StringComparers.ResourceName); entities.Add(new HealthModelEntity { Name = RootEntityName, - DisplayName = "Application", - Category = "Application", + DisplayName = "AppHost", + Category = "AppHost", Dependencies = DependenciesAggregation.WorstOf }); - entities.Add(new HealthModelEntity + foreach (var resource in modelResources) { - Name = ServicesEntityName, - DisplayName = "Services", - Category = "Logical component", - Dependencies = DependenciesAggregation.WorstOf - }); + entities.Add(CreateResourceEntity(resource)); + } - entities.Add(new HealthModelEntity + var edges = ResourceGraphHealth.BuildEdges(modelResources, showHiddenResources: false); + foreach (var name in ResourceGraphHealth.GetRootNames(modelResources, edges)) { - Name = InfrastructureEntityName, - DisplayName = "Infrastructure", - Category = "Logical component", - - // Infrastructure is backing services rather than the app itself, so a total failure here is - // reported to the application as degraded rather than unhealthy. - Impact = EntityImpact.Limited, - - // Any container that is not healthy makes the group unhealthy. There is deliberately no degraded - // threshold: limited impact swallows a degraded child entirely, so a degraded tier here would be - // invisible at the application level. Going straight to unhealthy means limited impact rewrites - // it to degraded and a single broken container is still surfaced on the application entity. - Dependencies = new DependenciesAggregation - { - AggregationType = DependenciesAggregationType.MaxNotHealthy, - UnhealthyThreshold = 1, - Unit = AggregationUnit.Absolute - } - }); - - relationships.Add(new HealthModelRelationship(RootEntityName, ServicesEntityName)); - relationships.Add(new HealthModelRelationship(RootEntityName, InfrastructureEntityName)); - - foreach (var resource in resources.OrderBy(r => r.Name, StringComparers.ResourceName)) + relationships.Add(new HealthModelRelationship(RootEntityName, entityNames[name])); + } + foreach (var edge in edges) { - if (resource.IsResourceHidden(showHiddenResources: false)) - { - continue; - } - - var parentName = GetParentEntityName(resource.ResourceType); - if (parentName is null) - { - continue; - } - - entities.Add(CreateResourceEntity(resource)); - relationships.Add(new HealthModelRelationship(parentName, GetEntityName(resource))); + relationships.Add(new HealthModelRelationship(entityNames[edge.ParentName], entityNames[edge.ChildName])); } return new HealthModelDefinition @@ -113,7 +70,7 @@ public static HealthModelDefinition Build(IEnumerable resourc Name = RootEntityName, DisplayName = "Application health", Entities = entities.ToImmutable(), - Relationships = relationships.ToImmutable() + Relationships = [.. relationships.OrderBy(r => r.ParentEntityName, StringComparer.Ordinal).ThenBy(r => r.ChildEntityName, StringComparer.Ordinal)] }; } @@ -129,27 +86,14 @@ public static string GetEntityName(ResourceViewModel resource) { ArgumentNullException.ThrowIfNull(resource); - return resource.PersistentKey; + // Preserve identity across runtime suffix changes and use only Azure-valid characters. The + // non-cryptographic suffix distinguishes display names that normalize to the same readable slug. + var slug = new string(resource.DisplayName.ToLowerInvariant() + .Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-').Take(48).ToArray()).Trim('-'); + var hash = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(resource.PersistentKey.ToLowerInvariant())); + return $"resource-{slug}-{hash.ToString("x16", CultureInfo.InvariantCulture)}"; } - private static string? GetParentEntityName(string resourceType) => resourceType switch - { - // Parameters and connection strings are configuration values resolved at startup. They have no - // runtime health of their own, so including them would add permanently unknown entities. - KnownResourceTypes.Parameter or KnownResourceTypes.ConnectionString => null, - - // Containers and external services are things the application depends on rather than the - // application itself, so they roll up through the limited-impact infrastructure entity. - KnownResourceTypes.Container - or KnownResourceTypes.ContainerExec - or KnownResourceTypes.ExternalService => InfrastructureEntityName, - - // Projects, executables and custom resource types are all treated as application services. Falling - // through by default rather than listing known types means a custom resource with health checks - // still appears in the model. - _ => ServicesEntityName - }; - private static HealthModelEntity CreateResourceEntity(ResourceViewModel resource) { var signals = ImmutableArray.CreateBuilder(resource.HealthReports.Length + 1); @@ -181,6 +125,9 @@ private static HealthModelEntity CreateResourceEntity(ResourceViewModel resource DisplayName = resource.DisplayName, Category = resource.ResourceType, ResourceName = resource.Name, + ResourceKey = resource.PersistentKey, + AspireResourceName = resource.DisplayName, + ReplicaIndex = resource.ReplicaIndex, ResourceType = resource.ResourceType, Signals = signals.ToImmutable() }; diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs new file mode 100644 index 00000000000..c70557a1175 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs @@ -0,0 +1,219 @@ +// 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.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Aspire.Dashboard.Model.HealthModel; + +/// Coordinates in the model's canvas space, independent of zoom and viewport size. +public sealed record HealthModelCanvasPosition(double X, double Y); + +/// A local signal binding, without measurements, descriptions, or exception data. +public sealed record HealthModelSignalBinding(string Name, SignalKind Kind); + +/// Portable configuration for an entity; observed health is deliberately excluded. +public sealed record HealthModelEntityConfiguration +{ + public required string Name { get; init; } + public required string DisplayName { get; init; } + public string? AspireResourceName { get; init; } + public int? ReplicaIndex { get; init; } + public required HealthModelCanvasPosition CanvasPosition { get; init; } + public EntityImpact Impact { get; init; } + public double? HealthObjective { get; init; } + public DependenciesAggregation Dependencies { get; init; } = DependenciesAggregation.WorstOf; + public ImmutableArray LocalSignals { get; init; } = []; +} + +/// A versioned, portable model definition for saved layout and future publishing integration. +/// +/// This is not an ARM template. Entity names, relationships, impact, dependency settings and canvas +/// coordinates map to Microsoft.CloudHealth entities. Local signal bindings still require an Azure +/// metric/query mapping or an external signal producer when a publisher consumes this definition. +/// +public sealed record HealthModelDocument +{ + [JsonRequired] + public int SchemaVersion { get; init; } = 1; + public required string Name { get; init; } + public required string ApplicationName { get; init; } + public required ImmutableArray Entities { get; init; } + public required ImmutableArray Relationships { get; init; } +} + +internal static partial class HealthModelDocuments +{ + public const int MaxFileSize = 2 * 1024 * 1024; + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 32, + Converters = { new JsonStringEnumConverter(allowIntegerValues: false) } + }; + + [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]$", RegexOptions.CultureInvariant)] + private static partial Regex EntityNamePattern(); + + public static HealthModelDocument Create(HealthModelDefinition definition, string applicationName) + { + var positions = HealthModelLayout.Arrange(definition); + return new HealthModelDocument + { + Name = definition.Name, + ApplicationName = applicationName, + Entities = [.. definition.Entities.Select(entity => new HealthModelEntityConfiguration + { + Name = entity.Name, + DisplayName = entity.DisplayName ?? entity.Name, + AspireResourceName = entity.AspireResourceName, + ReplicaIndex = entity.ReplicaIndex, + CanvasPosition = positions[entity.Name], + Impact = entity.Impact, + Dependencies = entity.Dependencies, + HealthObjective = entity.HealthObjective, + LocalSignals = [.. entity.Signals.Select(s => new HealthModelSignalBinding(s.Name, s.Kind))] + })], + Relationships = definition.Relationships + }; + } + + public static HealthModelDocument Reconcile(HealthModelDocument saved, HealthModelDefinition definition) + { + var current = Create(definition, saved.ApplicationName); + var savedEntities = saved.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); + return current with + { + Entities = [.. current.Entities.Select(entity => + savedEntities.TryGetValue(entity.Name, out var previous) + ? entity with + { + CanvasPosition = previous.CanvasPosition, + DisplayName = previous.DisplayName, + Impact = previous.Impact, + Dependencies = previous.Dependencies, + HealthObjective = previous.HealthObjective + } + : entity)] + }; + } + + public static HealthModelDefinition Apply(HealthModelDefinition live, HealthModelDocument document) + { + var settings = document.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); + return live with + { + Entities = [.. live.Entities.Select(entity => settings.TryGetValue(entity.Name, out var config) + ? entity with { DisplayName = config.DisplayName, Impact = config.Impact, Dependencies = config.Dependencies, HealthObjective = config.HealthObjective } + : entity)] + }; + } + + public static string Serialize(HealthModelDocument document) => JsonSerializer.Serialize(document, s_jsonOptions); + + public static HealthModelDocument Deserialize(string json, HealthModelDocument current) + { + // A model file has the shape { schemaVersion: 1, name, applicationName, entities: [...], + // relationships: [{ parentEntityName, childEntityName }] }. Unknown fields are rejected so + // runtime measurements or unsupported Azure settings cannot be silently discarded on import. + var document = JsonSerializer.Deserialize(json, s_jsonOptions) + ?? throw new InvalidDataException("The model document is null."); + Validate(document, current.ApplicationName); + if (document.Name != current.Name || + !document.Entities.Select(e => (e.Name, e.AspireResourceName, e.ReplicaIndex)).ToHashSet() + .SetEquals(current.Entities.Select(e => (e.Name, e.AspireResourceName, e.ReplicaIndex))) || + !document.Relationships.ToHashSet().SetEquals(current.Relationships)) + { + throw new InvalidDataException("The imported topology does not match this AppHost. Relationships are defined in the AppHost, not the designer."); + } + var currentEntities = current.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); + if (document.Entities.Any(e => !e.LocalSignals.ToHashSet().SetEquals(currentEntities[e.Name].LocalSignals))) + { + throw new InvalidDataException("Local signal bindings are defined by the AppHost and cannot be changed in an imported layout."); + } + return document; + } + + public static void Validate(HealthModelDocument document, string applicationName) + { + if (document.SchemaVersion != 1 || document.ApplicationName != applicationName || + document.Entities.IsDefaultOrEmpty || document.Entities.Length > 2000 || document.Relationships.IsDefault || + document.Relationships.Length > 10000 || string.IsNullOrEmpty(document.Name)) + { + throw new InvalidDataException("The model version, application or collection sizes are invalid."); + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var entity in document.Entities) + { + if (entity is null || string.IsNullOrEmpty(entity.Name) || !EntityNamePattern().IsMatch(entity.Name) || + !names.Add(entity.Name) || string.IsNullOrWhiteSpace(entity.DisplayName) || entity.DisplayName.Length > 260 || + entity.CanvasPosition is null || !IsValidPosition(entity.CanvasPosition) || + !Enum.IsDefined(entity.Impact) || entity.Dependencies is null || entity.LocalSignals.IsDefault || + entity.LocalSignals.Length > 1000 || entity.LocalSignals.Any(s => s is null || string.IsNullOrEmpty(s.Name) || !Enum.IsDefined(s.Kind)) || + entity.Name != document.Name && (string.IsNullOrEmpty(entity.AspireResourceName) || entity.ReplicaIndex is null) || + entity.ReplicaIndex is < 0 || entity.HealthObjective is { } objective && (!double.IsFinite(objective) || objective < 0 || objective > 100)) + { + throw new InvalidDataException("An entity has an invalid name, binding, position, impact or health objective."); + } + ValidateAggregation(entity.Dependencies); + } + if (!names.Contains(document.Name) || document.Entities.Single(e => e.Name == document.Name).Impact != EntityImpact.Standard || + document.Relationships.Any(r => r is null || r.ChildEntityName == document.Name || + !names.Contains(r.ParentEntityName) || !names.Contains(r.ChildEntityName))) + { + throw new InvalidDataException("The model must have a standard-impact root with valid relationships and no parent."); + } + + var definition = new HealthModelDefinition + { + Name = document.Name, + Entities = [.. document.Entities.Select(e => new HealthModelEntity { Name = e.Name })], + Relationships = document.Relationships + }; + var topology = HealthModelTopology.Create(definition); + var reachable = new HashSet(StringComparer.Ordinal) { document.Name }; + foreach (var entity in topology.Order.Where(e => reachable.Contains(e.Name))) + { + reachable.UnionWith(topology.Children[entity.Name]); + } + if (reachable.Count != names.Count) + { + throw new InvalidDataException("All entities must be reachable from the model root."); + } + } + + public static bool IsValidPosition(HealthModelCanvasPosition position) => + double.IsFinite(position.X) && double.IsFinite(position.Y) && + Math.Abs(position.X) <= 1_000_000 && Math.Abs(position.Y) <= 1_000_000; + + public static void ValidateAggregation(DependenciesAggregation aggregation) + { + if (!Enum.IsDefined(aggregation.AggregationType) || !Enum.IsDefined(aggregation.Unit)) + { + throw new InvalidDataException("The dependency aggregation type or unit is invalid."); + } + if (aggregation.AggregationType == DependenciesAggregationType.WorstOf) + { + if (aggregation.DegradedThreshold is not null || aggregation.UnhealthyThreshold is not null) + { + throw new InvalidDataException("Worst-of rollup does not accept thresholds."); + } + return; + } + + if (aggregation.UnhealthyThreshold is not { } unhealthy || !ValidThreshold(unhealthy) || + aggregation.DegradedThreshold is { } degraded && (!ValidThreshold(degraded) || + (aggregation.AggregationType == DependenciesAggregationType.MinHealthy ? degraded <= unhealthy : degraded >= unhealthy))) + { + throw new InvalidDataException("Set an unhealthy threshold and order the thresholds from degraded to unhealthy."); + } + + bool ValidThreshold(double value) => double.IsFinite(value) && value >= 0 && + (aggregation.Unit == AggregationUnit.Percentage ? value <= 100 : value == Math.Truncate(value)); + } +} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs index 5948414c3a4..d5199b42ec9 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs @@ -10,9 +10,10 @@ namespace Aspire.Dashboard.Model.HealthModel; /// component, a user flow, or a team. /// /// -/// Mirrors Microsoft.CloudHealth/healthmodels/entities. Azure has no entity kind discriminator, so -/// whether an entity represents a resource is determined structurally by whether -/// is set. On translation that becomes the presence of an azureResource signal group. +/// Uses the common configuration concepts of Microsoft.CloudHealth/healthmodels/entities. +/// identifies a local resource, not an ARM resource ID. A future publisher +/// must supply a deployed resource binding and signal source rather than copying the local name into +/// an Azure resource signal group. /// public sealed record HealthModelEntity { @@ -47,6 +48,15 @@ public sealed record HealthModelEntity /// The name of the Aspire resource this entity was projected from, when it represents one. public string? ResourceName { get; init; } + /// The stable local key used to associate designer settings with a resource replica. + public string? ResourceKey { get; init; } + + /// The AppHost resource name, without the runtime-generated instance suffix. + public string? AspireResourceName { get; init; } + + /// The replica index of the bound AppHost resource. + public int? ReplicaIndex { get; init; } + /// The type of the Aspire resource this entity was projected from, such as Project. public string? ResourceType { get; init; } @@ -84,7 +94,8 @@ public sealed record HealthModelRelationship(string ParentEntityName, string Chi /// through. Both arrive with deployment information rather than from the running app host. /// /// -/// Target the 2026-05-01-preview API version, which is the newest version with generated Bicep types. +/// The initial documented publishing target is the 2026-05-01-preview API version. +/// Azure coordinate mapping and execution parity require service-level validation. /// See https://learn.microsoft.com/azure/azure-monitor/health-models/tutorial-bicep. /// /// diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs index da9aa37f543..8069900c352 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs @@ -29,93 +29,51 @@ public static HealthModelSnapshot Evaluate(HealthModelDefinition definition) return new HealthModelSnapshot { Definition = definition, Root = null, AllNodes = [] }; } - var entitiesByName = definition.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); - - var childNamesByParent = definition.Relationships - .GroupBy(r => r.ParentEntityName, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.Select(r => r.ChildEntityName).ToArray(), StringComparer.Ordinal); - - // The root is the entity named after the model, matching the Azure convention where the root entity - // is created automatically using the health model's own name. Fall back to any entity that is never - // a child so a hand-built model without that convention still renders. - var root = entitiesByName.TryGetValue(definition.Name, out var namedRoot) - ? namedRoot - : FindImplicitRoot(definition); - - if (root is null) + var topology = HealthModelTopology.Create(definition); + var nodes = new Dictionary(StringComparer.Ordinal); + // Evaluate each entity once, from leaves up. Shared dependencies must not produce duplicate + // rows (or duplicate DOM keys) when the same entity is reached through several parents. + foreach (var entity in topology.Order.Reverse()) { - return new HealthModelSnapshot { Definition = definition, Root = null, AllNodes = [] }; - } - - var allNodes = ImmutableArray.CreateBuilder(); - - // Entities can legitimately have multiple parents, so an entity may be visited more than once. - // The visiting set only guards against cycles on the current path, which would otherwise recurse forever. - var visiting = new HashSet(StringComparer.Ordinal); - var rootNode = EvaluateEntity(root, depth: 0); - - return new HealthModelSnapshot - { - Definition = definition, - Root = rootNode, - AllNodes = allNodes.ToImmutable() - }; - - HealthModelNode EvaluateEntity(HealthModelEntity entity, int depth) - { - // Reserve this node's slot before recursing so children are appended after their parent and the - // flattened list comes out in depth-first render order. - var nodeIndex = allNodes.Count; - allNodes.Add(null!); - - var children = ImmutableArray.Empty; - - if (childNamesByParent.TryGetValue(entity.Name, out var childNames) && visiting.Add(entity.Name)) - { - try - { - var builder = ImmutableArray.CreateBuilder(childNames.Length); - foreach (var childName in childNames) - { - if (entitiesByName.TryGetValue(childName, out var child) && !visiting.Contains(childName)) - { - builder.Add(EvaluateEntity(child, depth + 1)); - } - } - - children = builder.ToImmutable(); - } - finally - { - visiting.Remove(entity.Name); - } - } - - var signalsState = entity.Signals.Length == 0 - ? HealthState.Unknown - : HealthStateExtensions.WorstOf(entity.Signals.Select(s => s.State)); - + var children = topology.Children[entity.Name].Select(n => nodes[n]).ToImmutableArray(); + var signalsState = HealthStateExtensions.WorstOf(entity.Signals.Select(s => s.State)); HealthState? dependenciesState = children.Length == 0 ? null : AggregateDependencies(entity.Dependencies, children); - - // Unknown is the least severe state, so an entity with no signals simply inherits its dependency - // state and an entity with no children is driven entirely by its own signals. No special casing needed. var state = HealthStateExtensions.WorstOf(signalsState, dependenciesState ?? HealthState.Unknown); - var node = new HealthModelNode + nodes.Add(entity.Name, new HealthModelNode { Entity = entity, State = state, SignalsState = signalsState, DependenciesState = dependenciesState, Children = children, - Depth = depth - }; + Depth = topology.Depth[entity.Name] + }); + } - allNodes[nodeIndex] = node; - return node; + var root = nodes.GetValueOrDefault(definition.Name) ?? nodes[topology.Order[0].Name]; + var allNodes = ImmutableArray.CreateBuilder(); + var visited = new HashSet(StringComparer.Ordinal); + var pending = new Stack([root]); + while (pending.TryPop(out var node)) + { + if (visited.Add(node.Name)) + { + allNodes.Add(node); + foreach (var child in node.Children.Reverse()) + { + pending.Push(child); + } + } } + foreach (var entity in topology.Order.Where(e => !visited.Contains(e.Name))) + { + allNodes.Add(nodes[entity.Name]); + } + + return new HealthModelSnapshot { Definition = definition, Root = root, AllNodes = allNodes.ToImmutable() }; } /// @@ -174,20 +132,4 @@ internal static HealthState AggregateDependencies(DependenciesAggregation aggreg }; } - private static HealthModelEntity? FindImplicitRoot(HealthModelDefinition definition) - { - var childNames = definition.Relationships.Select(r => r.ChildEntityName).ToHashSet(StringComparer.Ordinal); - - foreach (var entity in definition.Entities) - { - if (!childNames.Contains(entity.Name)) - { - return entity; - } - } - - // Every entity is a child of something, which means the model is a cycle. Fall back to the first - // entity so the UI still renders something rather than failing. - return definition.Entities.Length > 0 ? definition.Entities[0] : null; - } } diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs new file mode 100644 index 00000000000..14c60ab9267 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Localization; +using Strings = Aspire.Dashboard.Resources.HealthModel; + +namespace Aspire.Dashboard.Model.HealthModel; + +internal static class HealthModelLabels +{ + public static string State(HealthState state, IStringLocalizer loc) => loc[state switch + { + HealthState.Healthy => nameof(Strings.HealthModelHealthy), + HealthState.Degraded => nameof(Strings.HealthModelDegraded), + HealthState.Unhealthy => nameof(Strings.HealthModelUnhealthy), + _ => nameof(Strings.HealthModelUnknown) + }]; + + public static string Impact(EntityImpact impact, IStringLocalizer loc) => loc[impact switch + { + EntityImpact.Limited => nameof(Strings.HealthModelImpactLimited), + EntityImpact.Suppressed => nameof(Strings.HealthModelImpactSuppressed), + _ => nameof(Strings.HealthModelImpactStandard) + }]; + + public static string Aggregation(DependenciesAggregationType type, IStringLocalizer loc) => loc[type switch + { + DependenciesAggregationType.MinHealthy => nameof(Strings.HealthModelMinimumHealthy), + DependenciesAggregationType.MaxNotHealthy => nameof(Strings.HealthModelMaximumNotHealthy), + _ => nameof(Strings.HealthModelRollupWorstOf) + }]; +} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs new file mode 100644 index 00000000000..ef6735a4db5 --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model.HealthModel; + +internal static class HealthModelLayout +{ + public const double CardWidth = 224; + public const double CardHeight = 104; + private const double ColumnSpacing = 272; + private const double RowSpacing = 180; + + public static Dictionary Arrange(HealthModelDefinition definition) + { + var topology = HealthModelTopology.Create(definition); + var positions = new Dictionary(StringComparer.Ordinal); + var entities = definition.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); + var parents = definition.Relationships.GroupBy(r => r.ChildEntityName, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(r => r.ParentEntityName) + .OrderByDescending(n => topology.Depth[n]).ThenBy(n => n, StringComparer.Ordinal).First(), StringComparer.Ordinal); + var treeChildren = parents.ToLookup(pair => pair.Value, pair => pair.Key, StringComparer.Ordinal); + var roots = topology.Order.Where(e => !parents.ContainsKey(e.Name)).Select(e => e.Name).ToArray(); + var pending = new Stack<(string Name, bool Visited)>(Enumerable.Reverse(roots).Select(n => (n, false))); + var nextSlot = 0; + while (pending.TryPop(out var item)) + { + var children = treeChildren[item.Name] + .OrderBy(n => entities[n].DisplayName ?? n, StringComparer.Ordinal).ToArray(); + if (children.Length == 0) + { + positions[item.Name] = new(nextSlot++ * ColumnSpacing, topology.Depth[item.Name] * RowSpacing); + } + else if (item.Visited) + { + // A shared dependency has one positioning parent but keeps every relationship. Centre + // parents over their own branch instead of alphabetizing unrelated nodes across rows. + positions[item.Name] = new( + (positions[children[0]].X + positions[children[^1]].X) / 2, + topology.Depth[item.Name] * RowSpacing); + } + else + { + pending.Push((item.Name, true)); + foreach (var child in Enumerable.Reverse(children)) + { + pending.Push((child, false)); + } + } + } + var offset = positions.Count == 0 ? 0 : (positions.Values.Min(p => p.X) + positions.Values.Max(p => p.X)) / 2; + return positions.ToDictionary(pair => pair.Key, pair => pair.Value with { X = pair.Value.X - offset }, StringComparer.Ordinal); + } + + public static HealthModelCanvasPosition Place(HealthModelDocument document, string name, HealthModelCanvasPosition requested) + { + if (!HealthModelDocuments.IsValidPosition(requested)) + { + throw new InvalidDataException("The canvas position must contain finite coordinates within the supported canvas."); + } + var otherPositions = document.Entities.Where(e => e.Name != name).Select(e => e.CanvasPosition).ToArray(); + var snapped = new HealthModelCanvasPosition(Math.Round(requested.X / 8) * 8, Math.Round(requested.Y / 8) * 8); + if (IsFree(snapped)) + { + return snapped; + } + + // Keep the other saved positions intact. Find a nearby free slot for the dropped card instead + // of letting a force simulation rearrange positions that must round-trip to Azure. + for (var radius = 1; radius <= document.Entities.Length + 1; radius++) + { + for (var x = -radius; x <= radius; x++) + { + foreach (var y in new[] { -radius, radius }) + { + var candidate = new HealthModelCanvasPosition(snapped.X + x * ColumnSpacing, snapped.Y + y * RowSpacing); + if (IsFree(candidate)) + { + return candidate; + } + } + } + } + throw new InvalidDataException("There is no available position on the canvas."); + + bool IsFree(HealthModelCanvasPosition position) => HealthModelDocuments.IsValidPosition(position) && + otherPositions.All(other => Math.Abs(other.X - position.X) >= CardWidth + 16 || Math.Abs(other.Y - position.Y) >= CardHeight + 16); + } +} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs new file mode 100644 index 00000000000..e6455aade7d --- /dev/null +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs @@ -0,0 +1,55 @@ +// 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.Immutable; + +namespace Aspire.Dashboard.Model.HealthModel; + +internal sealed class HealthModelTopology +{ + public required ImmutableArray Order { get; init; } + public required ILookup Children { get; init; } + public required Dictionary Depth { get; init; } + + public static HealthModelTopology Create(HealthModelDefinition definition) + { + var entities = definition.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); + var incoming = entities.Keys.ToDictionary(n => n, _ => 0, StringComparer.Ordinal); + var depth = entities.Keys.ToDictionary(n => n, _ => 0, StringComparer.Ordinal); + var children = definition.Relationships.ToLookup(r => r.ParentEntityName, r => r.ChildEntityName, StringComparer.Ordinal); + var seen = new HashSet(); + foreach (var relationship in definition.Relationships) + { + if (!entities.ContainsKey(relationship.ParentEntityName) || + !entities.ContainsKey(relationship.ChildEntityName) || !seen.Add(relationship)) + { + throw new InvalidDataException("The health model contains a dangling or duplicate relationship."); + } + incoming[relationship.ChildEntityName]++; + } + + var ready = new Queue(definition.Entities.Where(e => incoming[e.Name] == 0).Select(e => e.Name)); + var order = ImmutableArray.CreateBuilder(); + while (ready.TryDequeue(out var name)) + { + order.Add(entities[name]); + foreach (var child in children[name]) + { + depth[child] = Math.Max(depth[child], depth[name] + 1); + if (--incoming[child] == 0) + { + ready.Enqueue(child); + } + } + } + + if (order.Count != entities.Count) + { + // A cyclic AppHost reference graph can still be inspected in Resources, but cannot form + // a health-model hierarchy with unambiguous parent-to-child propagation. + throw new InvalidDataException("Health model relationships must not contain a cycle."); + } + + return new HealthModelTopology { Order = order.ToImmutable(), Children = children, Depth = depth }; + } +} diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs index 4e3bacdaf43..b2681b0eadb 100644 --- a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceDto.cs @@ -16,6 +16,11 @@ public sealed class ResourceDto public required string? EndpointUrl { get; init; } public required string? EndpointText { get; init; } + /// + /// Whether this is the synthetic AppHost root rather than a resource with executable commands. + /// + public bool IsAppHost { get; init; } + /// /// The names of the resources this resource depends on. Each becomes a parent-to-child link in the graph. /// diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs index c2970bf94a7..35303534635 100644 --- a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphHealth.cs @@ -40,7 +40,7 @@ public static ImmutableArray BuildEdges(IEnumerable !r.IsResourceHidden(showHiddenResources)).ToList(); var edges = ImmutableArray.CreateBuilder(); var seen = new HashSet(); @@ -191,68 +191,41 @@ public static Dictionary ComputeEffectiveStates(IEnumerable { ArgumentNullException.ThrowIfNull(resources); - var ownStates = new Dictionary(StringComparers.ResourceName); + var effectiveStates = new Dictionary(StringComparers.ResourceName); foreach (var resource in resources) { - ownStates[resource.Name] = GetOwnState(resource); + effectiveStates[resource.Name] = GetOwnState(resource); } - var childrenByParent = edges - .GroupBy(e => e.ParentName, StringComparers.ResourceName) - .ToDictionary(g => g.Key, g => g.Select(e => e.ChildName).ToArray(), StringComparers.ResourceName); + var parentsByChild = edges + .Where(e => effectiveStates.ContainsKey(e.ParentName) && effectiveStates.ContainsKey(e.ChildName)) + .ToLookup(e => e.ChildName, e => e.ParentName, StringComparers.ResourceName); - var effectiveStates = new Dictionary(StringComparers.ResourceName); - - // A resource can be reached through several paths, so results are memoized. The visiting set only - // guards the current path: dependency chains are not guaranteed to be acyclic once Reference - // relationships are involved, and a cycle would otherwise recurse forever. - var visiting = new HashSet(StringComparers.ResourceName); + // Propagate changes back to dependents until stable. Memoizing a recursive walk can cache a + // partially evaluated cycle (a -> b -> a, with a -> unhealthy-leaf), leaving b falsely healthy. + // States only increase in severity within this snapshot, so even cycles converge in at most + // three changes per resource. Starting from own state on each call also allows recovery. + var pending = new Queue(effectiveStates.Keys); + var queued = new HashSet(effectiveStates.Keys, StringComparers.ResourceName); - foreach (var name in ownStates.Keys) + while (pending.TryDequeue(out var child)) { - Resolve(name); - } - - return effectiveStates; - - HealthState Resolve(string name) - { - if (effectiveStates.TryGetValue(name, out var cached)) + queued.Remove(child); + foreach (var parent in parentsByChild[child]) { - return cached; - } - - if (!ownStates.TryGetValue(name, out var state)) - { - return HealthState.Unknown; - } - - if (!visiting.Add(name)) - { - // Re-entering a resource already on the current path. Return its own state so the cycle - // contributes something without recursing, and leave the memo unset so the fully resolved - // value is still computed by the outer frame. - return state; - } - - try - { - if (childrenByParent.TryGetValue(name, out var children)) + var state = HealthStateExtensions.WorstOf(effectiveStates[parent], effectiveStates[child]); + if (state != effectiveStates[parent]) { - foreach (var child in children) + effectiveStates[parent] = state; + if (queued.Add(parent)) { - state = HealthStateExtensions.WorstOf(state, Resolve(child)); + pending.Enqueue(parent); } } } - finally - { - visiting.Remove(name); - } - - effectiveStates[name] = state; - return state; } + + return effectiveStates; } /// diff --git a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs index a23d2a717a3..d856df0ab69 100644 --- a/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs +++ b/src/Aspire.Dashboard/Model/ResourceGraph/ResourceGraphMapper.cs @@ -80,6 +80,7 @@ private static ResourceDto CreateAppHostResource(string applicationName, Immutab return new ResourceDto { Name = AppHostEntityName, + IsAppHost = true, ResourceType = ControlsStrings.ResourceGraphAppHostType, DisplayName = applicationName, Uid = AppHostEntityName, diff --git a/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs b/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs index 49cb1636a8f..26d3cc7f5ee 100644 --- a/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs +++ b/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs @@ -23,6 +23,134 @@ namespace Aspire.Dashboard.Resources { [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class HealthModel { + /// Graph. + public static string HealthModelGraphTab => ResourceManager.GetString("HealthModelGraphTab", resourceCulture); + /// Entities. + public static string HealthModelEntitiesTab => ResourceManager.GetString("HealthModelEntitiesTab", resourceCulture); + /// Designer. + public static string HealthModelDesignerTab => ResourceManager.GetString("HealthModelDesignerTab", resourceCulture); + /// Local preview. + public static string HealthModelLocalPreview => ResourceManager.GetString("HealthModelLocalPreview", resourceCulture); + /// Live health from AppHost dependencies. + public static string HealthModelGraphHint => ResourceManager.GetString("HealthModelGraphHint", resourceCulture); + /// Instructions for editing the model. + public static string HealthModelDesignerHint => ResourceManager.GetString("HealthModelDesignerHint", resourceCulture); + /// Save changes. + public static string HealthModelSave => ResourceManager.GetString("HealthModelSave", resourceCulture); + /// Discard changes. + public static string HealthModelDiscard => ResourceManager.GetString("HealthModelDiscard", resourceCulture); + /// Arrange. + public static string HealthModelArrange => ResourceManager.GetString("HealthModelArrange", resourceCulture); + /// Undo. + public static string HealthModelUndo => ResourceManager.GetString("HealthModelUndo", resourceCulture); + /// Fit to view. + public static string HealthModelFit => ResourceManager.GetString("HealthModelFit", resourceCulture); + /// Zoom in. + public static string HealthModelZoomIn => ResourceManager.GetString("HealthModelZoomIn", resourceCulture); + /// Zoom out. + public static string HealthModelZoomOut => ResourceManager.GetString("HealthModelZoomOut", resourceCulture); + /// Export model. + public static string HealthModelExport => ResourceManager.GetString("HealthModelExport", resourceCulture); + /// Import model. + public static string HealthModelImport => ResourceManager.GetString("HealthModelImport", resourceCulture); + /// Unsaved changes. + public static string HealthModelUnsaved => ResourceManager.GetString("HealthModelUnsaved", resourceCulture); + /// Saved in this browser. + public static string HealthModelSaved => ResourceManager.GetString("HealthModelSaved", resourceCulture); + /// Default layout. + public static string HealthModelDefaultLayout => ResourceManager.GetString("HealthModelDefaultLayout", resourceCulture); + /// Confirmation after saving the model. + public static string HealthModelSaveSuccess => ResourceManager.GetString("HealthModelSaveSuccess", resourceCulture); + /// Error saving the model. + public static string HealthModelSaveError => ResourceManager.GetString("HealthModelSaveError", resourceCulture); + /// Error loading the saved model. + public static string HealthModelLoadError => ResourceManager.GetString("HealthModelLoadError", resourceCulture); + /// Error importing a model. + public static string HealthModelImportError => ResourceManager.GetString("HealthModelImportError", resourceCulture); + /// Confirmation after importing into the draft. + public static string HealthModelImportSuccess => ResourceManager.GetString("HealthModelImportSuccess", resourceCulture); + /// Error downloading the model. + public static string HealthModelExportError => ResourceManager.GetString("HealthModelExportError", resourceCulture); + /// Unsupported AppHost topology. + public static string HealthModelInvalidTopology => ResourceManager.GetString("HealthModelInvalidTopology", resourceCulture); + /// Resource updates have stopped. + public static string HealthModelSubscriptionError => ResourceManager.GetString("HealthModelSubscriptionError", resourceCulture); + /// Historical models are read-only. + public static string HealthModelReadOnly => ResourceManager.GetString("HealthModelReadOnly", resourceCulture); + /// Features outside this local preview. + public static string HealthModelCloudBoundary => ResourceManager.GetString("HealthModelCloudBoundary", resourceCulture); + /// Purpose of the exported model definition. + public static string HealthModelDefinitionHint => ResourceManager.GetString("HealthModelDefinitionHint", resourceCulture); + /// Entity and relationship counts. + public static string HealthModelEntityCount => ResourceManager.GetString("HealthModelEntityCount", resourceCulture); + /// Healthy. + public static string HealthModelHealthy => ResourceManager.GetString("HealthModelHealthy", resourceCulture); + /// Degraded. + public static string HealthModelDegraded => ResourceManager.GetString("HealthModelDegraded", resourceCulture); + /// Unhealthy. + public static string HealthModelUnhealthy => ResourceManager.GetString("HealthModelUnhealthy", resourceCulture); + /// Unknown. + public static string HealthModelUnknown => ResourceManager.GetString("HealthModelUnknown", resourceCulture); + /// All health states. + public static string HealthModelAllStates => ResourceManager.GetString("HealthModelAllStates", resourceCulture); + /// No matching entities. + public static string HealthModelNoMatch => ResourceManager.GetString("HealthModelNoMatch", resourceCulture); + /// Display name. + public static string HealthModelDisplayName => ResourceManager.GetString("HealthModelDisplayName", resourceCulture); + /// Entity ID. + public static string HealthModelEntityId => ResourceManager.GetString("HealthModelEntityId", resourceCulture); + /// Canvas X. + public static string HealthModelPositionX => ResourceManager.GetString("HealthModelPositionX", resourceCulture); + /// Canvas Y. + public static string HealthModelPositionY => ResourceManager.GetString("HealthModelPositionY", resourceCulture); + /// Health objective. + public static string HealthModelHealthObjective => ResourceManager.GetString("HealthModelHealthObjective", resourceCulture); + /// Health objective limitations. + public static string HealthModelObjectiveHint => ResourceManager.GetString("HealthModelObjectiveHint", resourceCulture); + /// Standard impact. + public static string HealthModelImpactStandard => ResourceManager.GetString("HealthModelImpactStandard", resourceCulture); + /// Limited impact. + public static string HealthModelImpactLimited => ResourceManager.GetString("HealthModelImpactLimited", resourceCulture); + /// Suppressed impact. + public static string HealthModelImpactSuppressed => ResourceManager.GetString("HealthModelImpactSuppressed", resourceCulture); + /// How impact affects parents. + public static string HealthModelImpactHint => ResourceManager.GetString("HealthModelImpactHint", resourceCulture); + /// Minimum healthy. + public static string HealthModelMinimumHealthy => ResourceManager.GetString("HealthModelMinimumHealthy", resourceCulture); + /// Maximum not healthy. + public static string HealthModelMaximumNotHealthy => ResourceManager.GetString("HealthModelMaximumNotHealthy", resourceCulture); + /// Entity count. + public static string HealthModelAbsolute => ResourceManager.GetString("HealthModelAbsolute", resourceCulture); + /// Percentage. + public static string HealthModelPercentage => ResourceManager.GetString("HealthModelPercentage", resourceCulture); + /// Threshold unit. + public static string HealthModelThresholdUnit => ResourceManager.GetString("HealthModelThresholdUnit", resourceCulture); + /// Optional degraded threshold. + public static string HealthModelDegradedThreshold => ResourceManager.GetString("HealthModelDegradedThreshold", resourceCulture); + /// Unhealthy threshold. + public static string HealthModelUnhealthyThreshold => ResourceManager.GetString("HealthModelUnhealthyThreshold", resourceCulture); + /// Ignore unknown dependencies. + public static string HealthModelIgnoreUnknown => ResourceManager.GetString("HealthModelIgnoreUnknown", resourceCulture); + /// Threshold evaluation directions. + public static string HealthModelThresholdHint => ResourceManager.GetString("HealthModelThresholdHint", resourceCulture); + /// Apply to draft. + public static string HealthModelApply => ResourceManager.GetString("HealthModelApply", resourceCulture); + /// Invalid entity settings. + public static string HealthModelInvalidSettings => ResourceManager.GetString("HealthModelInvalidSettings", resourceCulture); + /// Parents. + public static string HealthModelParents => ResourceManager.GetString("HealthModelParents", resourceCulture); + /// Signals reported by the local AppHost. + public static string HealthModelSignalsSource => ResourceManager.GetString("HealthModelSignalsSource", resourceCulture); + /// Health seen by parents. + public static string HealthModelPropagation => ResourceManager.GetString("HealthModelPropagation", resourceCulture); + /// Accessible canvas label. + public static string HealthModelCanvasLabel => ResourceManager.GetString("HealthModelCanvasLabel", resourceCulture); + /// Accessible relationship label. + public static string HealthModelRelationshipLabel => ResourceManager.GetString("HealthModelRelationshipLabel", resourceCulture); + /// Accessible entity label. + public static string HealthModelNodeLabel => ResourceManager.GetString("HealthModelNodeLabel", resourceCulture); + /// Select an entity to edit. + public static string HealthModelFocusDesigner => ResourceManager.GetString("HealthModelFocusDesigner", resourceCulture); private static global::System.Resources.ResourceManager resourceMan; diff --git a/src/Aspire.Dashboard/Resources/HealthModel.resx b/src/Aspire.Dashboard/Resources/HealthModel.resx index 895411cdcd9..cab3d6d64f4 100644 --- a/src/Aspire.Dashboard/Resources/HealthModel.resx +++ b/src/Aspire.Dashboard/Resources/HealthModel.resx @@ -199,4 +199,68 @@ View resource + Graph + Entities + Designer + Local preview + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Save changes + Discard changes + Arrange + Undo + Fit to view + Zoom in + Zoom out + Export model + Import model + Unsaved changes + Saved in this browser + Default layout + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + The model could not be saved. Your changes are still available in the designer. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + The model could not be downloaded. The saved model has not changed. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + This is a historical run. Switch to the live run to edit the model. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + {0} entities, {1} relationships{0} is an entity count, {1} is a relationship count. + Healthy + Degraded + Unhealthy + Unknown + All health states + No entities match the current filter. + Display name + Entity ID + Canvas X + Canvas Y + Health objective (%) + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Standard + Limited + Suppressed + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Minimum healthy + Maximum not healthy + Entity count + Percentage + Threshold unit + Degraded threshold (optional) + Unhealthy threshold + Ignore unknown dependencies + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Apply to draft + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Parents + Signals reported by the local AppHost + Health seen by parents + Application health model. Select an entity to inspect it. + {0} depends on {1}: {2}Parent entity name, child entity name and health state. + {0}: {1}. {2} signals.Entity display name, health state and signal count. + Select an entity to edit its propagation settings and canvas position. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf index 2b238d4a71b..df231f93842 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf index 30221b257a5..60eba5937b1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf index 86d1cdc3839..93918ef2cc7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf index 76a786fb427..4ac48a2c2a9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf index 8b9a5d9b256..062c205a8d8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf index db353b5f9b8..726386ad6d2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf index 5bfa7908e59..a49a39900da 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf index 3a275dd33b0..16d8fd1e7ee 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf index 7caa3a7a00a..b0892c6f0fe 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf index 379bf674815..649d8194440 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf index d817cb0a7ea..66650d99cbc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf index 0b659cd42a8..56e985fe6a1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf index 8c35832b32b..ed8f9b702f6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf @@ -2,6 +2,56 @@ + + Entity count + Entity count + + + + All health states + All health states + + + + Apply to draft + Apply to draft + + + + Arrange + Arrange + + + + Application health model. Select an entity to inspect it. + Application health model. Select an entity to inspect it. + + + + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + + + + Default layout + Default layout + + + + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + + + + Degraded + Degraded + + + + Degraded threshold (optional) + Degraded threshold (optional) + + Dependencies Dependencies @@ -12,16 +62,81 @@ Health states roll up from resources to the logical components that depend on them. + + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. + + + + Designer + Designer + + Details Details + + Discard changes + Discard changes + + + + Display name + Display name + + + + Entities + Entities + + Entity Entity + + {0} entities, {1} relationships + {0} entities, {1} relationships + {0} is an entity count, {1} is a relationship count. + + + Entity ID + Entity ID + + + + Export model + Export model + + + + The model could not be downloaded. The saved model has not changed. + The model could not be downloaded. The saved model has not changed. + + + + Fit to view + Fit to view + + + + Select an entity to edit its propagation settings and canvas position. + Select an entity to edit its propagation settings and canvas position. + + + + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. + + + + Graph + Graph + + Health model Health model @@ -32,6 +147,86 @@ Health + + Health objective (%) + Health objective (%) + + + + Healthy + Healthy + + + + Ignore unknown dependencies + Ignore unknown dependencies + + + + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. + + + + Limited + Limited + + + + Standard + Standard + + + + Suppressed + Suppressed + + + + Import model + Import model + + + + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. + + + + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + Model imported into the designer. Save changes to keep it, or discard to return to the previous model. + + + + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. + + + + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. + + + + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + The saved model is not valid for this application. The live AppHost topology is shown with its default layout. + + + + Local preview + Local preview + + + + Maximum not healthy + Maximum not healthy + + + + Minimum healthy + Minimum healthy + + This entity has no dependencies. This entity has no dependencies. @@ -42,16 +237,56 @@ No entities in the health model. + + No entities match the current filter. + No entities match the current filter. + + This entity has no signals of its own. Its health comes entirely from its dependencies. This entity has no signals of its own. Its health comes entirely from its dependencies. + + {0}: {1}. {2} signals. + {0}: {1}. {2} signals. + Entity display name, health state and signal count. + + + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + Target percentage of healthy time. Stored for deployment; local availability history is not measured. + + {0} health model {0} health model {0} is an application name + + Parents + Parents + + + + Percentage + Percentage + + + + Canvas X + Canvas X + + + + Canvas Y + Canvas Y + + + + Health seen by parents + Health seen by parents + + From dependencies From dependencies @@ -77,6 +312,16 @@ From signals + + This is a historical run. Switch to the live run to edit the model. + This is a historical run. Switch to the live run to edit the model. + + + + {0} depends on {1}: {2} + {0} depends on {1}: {2} + Parent entity name, child entity name and health state. + Rollup Rollup @@ -97,6 +342,26 @@ Worst of + + Save changes + Save changes + + + + The model could not be saved. Your changes are still available in the designer. + The model could not be saved. Your changes are still available in the designer. + + + + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. + + + + Saved in this browser + Saved in this browser + + Signal Signal @@ -117,21 +382,76 @@ Signals + + Signals reported by the local AppHost + Signals reported by the local AppHost + + State State + + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. + + + + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. + + + + Threshold unit + Threshold unit + + Type Type + + Undo + Undo + + + + Unhealthy + Unhealthy + + + + Unhealthy threshold + Unhealthy threshold + + + + Unknown + Unknown + + + + Unsaved changes + Unsaved changes + + View resource View resource + + Zoom in + Zoom in + + + + Zoom out + Zoom out + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js b/src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js new file mode 100644 index 00000000000..a9d84fb9b98 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js @@ -0,0 +1,145 @@ +import './d3.v7.min.js'; + +export function createHealthModelGraph(svg, interop) { + return new HealthModelGraph(svg, interop); +} + +class HealthModelGraph { + constructor(svg, interop) { + this.svg = d3.select(svg); + this.viewport = this.svg.select('.health-model-viewport'); + this.interop = interop; + this.positions = new Map(); + this.editable = false; + this.active = null; + this.disposed = false; + this.manuallyFramed = false; + this.zoom = d3.zoom().scaleExtent([0.05, 3]).on('zoom', event => { + this.viewport.attr('transform', event.transform); + if (event.sourceEvent) this.manuallyFramed = true; + }); + this.svg.call(this.zoom).on('dblclick.zoom', null); + this.observer = new ResizeObserver(() => this.resize()); + this.observer.observe(svg.parentElement); + this.drag = d3.drag() + .filter(event => this.editable && !event.button && !event.ctrlKey && !this.active) + .subject((event, position) => position) + .clickDistance(3) + .on('start', (event) => { + this.active = event.subject; + this.moved = false; + }) + .on('drag', event => { + if (this.disposed || this.active !== event.subject) return; + this.moved = true; + this.active.x = event.x; + this.active.y = event.y; + this.drawPositions(); + }) + .on('end', () => this.finishDrag()); + this.blur = () => { + if (this.active) { + d3.select(window).on('.drag', null); + d3.dragEnable(window, true); + this.finishDrag(); + } + }; + window.addEventListener('blur', this.blur); + this.resize(); + } + + update(positions, editable) { + this.editable = editable; + const topologyChanged = positions.length !== this.positions.size || + positions.some(position => !this.positions.has(position.name)); + const next = new Map(); + for (const value of positions) { + const current = this.positions.get(value.name) || { name: value.name }; + if (current !== this.active) Object.assign(current, value); + next.set(value.name, current); + } + this.positions = next; + this.svg.selectAll('.health-model-entity').each((_, index, elements) => { + const element = elements[index]; + d3.select(element).datum(this.positions.get(element.dataset.entity)) + .call(this.drag) + .on('keydown.health-model', event => { + const position = this.positions.get(element.dataset.entity); + if (!position) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.interop.invokeMethodAsync('SelectEntity', position.name); + } else if (this.editable && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + event.preventDefault(); + const step = event.shiftKey ? 64 : 8; + const x = position.x + (event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0); + const y = position.y + (event.key === 'ArrowDown' ? step : event.key === 'ArrowUp' ? -step : 0); + this.interop.invokeMethodAsync('MoveEntity', position.name, x, y); + } + }); + }); + this.drawPositions(); + if (topologyChanged && !this.manuallyFramed) this.fit(); + } + + async finishDrag() { + const position = this.active; + this.active = null; + if (position && this.moved && !this.disposed) { + this.manuallyFramed = true; + await this.interop.invokeMethodAsync('MoveEntity', position.name, position.x, position.y); + } + } + + drawPositions() { + this.svg.selectAll('.health-model-entity').attr('transform', position => `translate(${position.x},${position.y})`); + this.svg.selectAll('.health-model-edge').attr('d', (_, index, elements) => { + const edge = elements[index]; + const parent = this.positions.get(edge.dataset.parent); + const child = this.positions.get(edge.dataset.child); + const y1 = parent.y + 52, y2 = child.y - 52, middle = (y1 + y2) / 2; + return `M ${parent.x} ${y1} C ${parent.x} ${middle}, ${child.x} ${middle}, ${child.x} ${y2}`; + }); + } + + resize() { + const container = this.svg.node().parentElement; + if (!container.clientWidth || !container.clientHeight) return; + this.svg.attr('viewBox', `0 0 ${container.clientWidth} ${container.clientHeight}`); + if (!this.manuallyFramed && this.positions.size) this.fit(); + } + + fit() { + if (!this.positions.size) return; + const container = this.svg.node().parentElement; + if (!container.clientWidth || !container.clientHeight) return; + const values = [...this.positions.values()]; + const minX = Math.min(...values.map(p => p.x)) - 140; + const minY = Math.min(...values.map(p => p.y)) - 84; + const width = Math.max(...values.map(p => p.x)) + 140 - minX; + const height = Math.max(...values.map(p => p.y)) + 84 - minY; + const scale = Math.min(1, container.clientWidth / width, container.clientHeight / height); + this.manuallyFramed = false; + const transform = d3.zoomIdentity + .translate(container.clientWidth / 2, container.clientHeight / 2) + .scale(scale).translate(-minX - width / 2, -minY - height / 2); + this.svg.call(this.zoom.transform, transform); + } + + zoomBy(factor) { + this.manuallyFramed = true; + this.svg.call(this.zoom.scaleBy, factor); + } + + dispose() { + this.disposed = true; + this.observer.disconnect(); + window.removeEventListener('blur', this.blur); + if (this.active) { + d3.select(window).on('.drag', null); + d3.dragEnable(window, true); + } + this.svg.on('.zoom', null); + this.svg.selectAll('.health-model-entity').on('.drag', null).on('.health-model', null); + } +} diff --git a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js index d172aeb91d9..486d62b37cd 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js @@ -8,23 +8,17 @@ const SIBLING_SPACING = 210; let resourceGraph = null; -export function initializeResourcesGraph(resourcesInterop, graphIcons) { - resourceGraph = new ResourceGraph(resourcesInterop, graphIcons); +export function initializeResourcesGraph(resourcesInterop, graphIcons, instanceId) { + resourceGraph?.dispose(); + resourceGraph = new ResourceGraph(resourcesInterop, graphIcons, instanceId); resourceGraph.resize(); +} - const observer = new ResizeObserver(function () { - resourceGraph.resize(); - }); - - // The graph container is what actually bounds the drawing area, but it starts hidden while another tab - // is selected, so the summary layout is observed too to catch the switch back to the graph. - const graphContainer = document.querySelector('.resource-graph-container'); - if (graphContainer) { - observer.observe(graphContainer); - } - - for (const child of document.getElementsByClassName('resources-summary-layout')) { - observer.observe(child); +export function disposeResourcesGraph(instanceId) { + // An old Blazor page can finish disposing after the replacement page has initialized its graph. + if (resourceGraph?.instanceId === instanceId) { + resourceGraph.dispose(); + resourceGraph = null; } } @@ -40,8 +34,15 @@ export function updateResourcesGraphSelected(resourceName) { } } +export function focusResourceMenuItem(instanceId, itemId, anchorId) { + return resourceGraph?.instanceId === instanceId + ? resourceGraph.focusMenuItem(itemId, anchorId) + : Promise.resolve(false); +} + class ResourceGraph { - constructor(resourcesInterop, graphIcons) { + constructor(resourcesInterop, graphIcons, instanceId) { + this.instanceId = instanceId; this.resources = []; this.resourcesInterop = resourcesInterop; this.openContextMenu = false; @@ -53,11 +54,12 @@ class ResourceGraph { this.links = []; this.svg = d3.select('.resource-graph'); + this.container = this.svg.node().closest('.resource-graph-container'); this.baseGroup = this.svg.append("g"); - // Set while a node is being dragged. Collision is skipped for that node so it can be moved freely - // over the top of others instead of being shouldered away by them. this.draggingNodeId = null; + this.activeDrag = null; + this.dragMoved = false; // The view is auto-fitted to the graph until the user zooms or pans, after which their framing is // left alone. @@ -88,71 +90,49 @@ class ResourceGraph { .forceSimulation() .force('link', this.linkForce) .force('charge', d3.forceManyBody().strength(-900).distanceMax(700)) - .force("collide", d3.forceCollide((node) => { - // A node being dragged has no collision radius, so it slides over its neighbours instead of - // pushing them around. Everything else keeps a radius, which is what stops nodes from - // sitting on top of each other once the graph is static again. - return node.id === this.draggingNodeId ? 0 : NODE_COLLIDE_RADIUS; - }).iterations(4)) + .force("collide", d3.forceCollide(NODE_COLLIDE_RADIUS).iterations(4)) // These two are what turn a floating force layout into a hierarchy. Y is pinned hard to the // node's depth so every generation forms a row, while X only nudges each node toward the slot // computed for it so collision can still spread crowded rows out. .force("y", d3.forceY((node) => node.targetY || 0).strength(1)) .force("x", d3.forceX((node) => node.targetX || 0).strength(0.25)); - // Drag start is trigger on mousedown from click. - // Only change the state of the simulation when the drag event is triggered. - var dragActive = false; - var dragged = false; - this.dragDrop = d3.drag().on('start', (event) => { - dragActive = event.active; - dragged = false; - - // Reset defensively. If a previous gesture never delivered its end event (the browser losing - // focus mid-drag will do this) the node would otherwise stay excluded from collision forever. - this.draggingNodeId = null; - - event.subject.fx = event.subject.x; - event.subject.fy = event.subject.y; - }).on('drag', (event) => { - if (!dragActive) { - this.simulation.alphaTarget(0.1).restart(); - dragActive = true; - } - if (!dragged) { - dragged = true; - - // Drop the node out of collision for the duration of the drag so it can be moved anywhere, - // including straight over other nodes, rather than being blocked by whatever is nearby. + this.dragDrop = d3.drag() + .filter(event => !event.ctrlKey && !event.button && this.activeDrag === null) + .clickDistance(3) + .on('start', (event) => { + this.activeDrag = event.subject; this.draggingNodeId = event.subject.id; - } - event.subject.fx = event.x; - event.subject.fy = event.y; - }).on('end', (event) => { - if (dragged) { - this.simulation.alphaTarget(0); - dragged = false; - this.draggingNodeId = null; - - // Keep fx/fy so the node stays where it was dropped instead of springing back to wherever - // the simulation wants it. Double clicking the node releases it again. - event.subject.pinned = true; - - // Collision can't move a node that is fixed in place, so anything else already pinned on - // this spot would stay overlapped forever. Release those back to the simulation and let it - // push them clear, which keeps the most recent drop as the one that wins. - this.releasePinnedNodesOverlapping(event.subject); - - this.updateNodePinnedState(); - this.simulation.alpha(0.4).restart(); - } - else { - // Mousedown without movement is a click, not a drag, so release the temporary fixing applied - // on start. Pinning here would make every click on a node pin it. - if (!event.subject.pinned) { - event.subject.fx = null; - event.subject.fy = null; + this.dragMoved = false; + this.simulation.stop(); + event.subject.fx = event.subject.x; + event.subject.fy = event.subject.y; + }) + .on('drag', (event) => { + if (this.activeDrag !== event.subject) { + return; + } + + // Move directly under the pointer with physics paused. D3 caches collision radii, and + // even a zero-radius node still collides with its neighbours' nonzero radii. + if (!this.dragMoved) { + // Reparenting on mousedown also suppresses ordinary click events in browsers. + this.nodeElements.filter(node => node === event.subject).raise(); } + this.dragMoved = true; + this.userAdjustedView = true; + event.subject.x = event.subject.fx = event.x; + event.subject.y = event.subject.fy = event.y; + event.subject.vx = event.subject.vy = 0; + this.onTick(); + }) + .on('end', () => this.finishDrag()); + + d3.select(window).on('blur.resource-graph', () => { + if (this.activeDrag) { + d3.select(window).on('.drag', null); + d3.dragEnable(window, true); + this.finishDrag(); } }); @@ -213,8 +193,85 @@ class ResourceGraph { this.linkElementsG = this.baseGroup.append("g").attr("class", "links"); this.nodeElementsG = this.baseGroup.append("g").attr("class", "nodes"); + this.linkElements = this.linkElementsG.selectAll("line"); + this.nodeElements = this.nodeElementsG.selectAll(".resource-group"); this.initializeButtons(); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(this.container); + } + + dispose() { + this.cancelMenuFocus?.(); + this.simulation.stop(); + this.resizeObserver.disconnect(); + if (this.activeDrag) { + d3.select(window).on('.drag', null); + d3.dragEnable(window, true); + } + d3.select(window).on('blur.resource-graph', null); + this.svg.interrupt().on('.zoom', null); + this.svg.selectAll('*').interrupt().remove(); + d3.select(this.container).selectAll('.graph-zoom-in, .graph-zoom-out, .graph-reset').on('click', null); + } + + focusMenuItem(itemId, anchorId) { + this.cancelMenuFocus?.(); + return new Promise(resolve => { + const complete = focused => { + observer.disconnect(); + clearTimeout(timeout); + this.cancelMenuFocus = null; + resolve(focused); + }; + const tryFocus = () => { + const item = document.getElementById(itemId); + const anchor = document.getElementById(anchorId); + // Fluent renders popup items separately and assigns tabindex when its web components + // are ready. Its anchor's aria-expanded also signals keyboard-listener initialization. + if (anchor?.getAttribute('aria-expanded') === 'true' && + item?.hasAttribute('tabindex') && item.getClientRects().length > 0) { + item.focus(); + if (document.activeElement === item || item.contains(document.activeElement)) { + complete(true); + } + } + }; + const observer = new MutationObserver(tryFocus); + const timeout = setTimeout(() => complete(false), 5000); + this.cancelMenuFocus = () => complete(false); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['aria-expanded', 'tabindex', 'hidden', 'style', 'class'] + }); + tryFocus(); + }); + } + + finishDrag() { + const node = this.activeDrag; + if (!node) { + return; + } + + this.activeDrag = null; + this.draggingNodeId = null; + if (this.nodes.includes(node)) { + if (this.dragMoved) { + node.pinned = true; + this.releasePinnedNodesOverlapping(node); + } else if (!node.pinned) { + node.fx = node.fy = null; + } + } + + this.updateNodePinnedState(); + if (this.dragMoved) { + this.simulation.alpha(0.4); + } + this.simulation.alphaTarget(0).restart(); } initializeButtons() { @@ -224,25 +281,30 @@ class ResourceGraph { } resetZoomAndPan() { - this.svg.transition().call(this.zoom.transform, d3.zoomIdentity); + this.svg.interrupt(); + this.simulation.stop(); + this.userAdjustedView = false; this.unpinAllNodes(); + this.computeHierarchy(); + for (const node of this.nodes) { + node.x = node.targetX; + node.y = node.targetY; + node.vx = node.vy = 0; + } + this.simulation.nodes(this.nodes).alphaTarget(0).alpha(1); + this.simulation.tick(300); + this.onTick(); + this.fitToView(); } // Releases every pinned node so the layout is driven by the simulation again. - unpinAllNodes() { var hasPinnedNodes = false; + unpinAllNodes() { for (const node of this.nodes) { - if (node.pinned) { - node.pinned = false; - node.fx = null; - node.fy = null; - hasPinnedNodes = true; - } - } - - if (hasPinnedNodes) { - this.updateNodePinnedState(); - this.simulation.alpha(0.3).restart(); + node.pinned = false; + node.fx = null; + node.fy = null; } + this.updateNodePinnedState(); } // Reflects the pinned state of each node in the DOM so it can be styled. @@ -403,37 +465,34 @@ class ResourceGraph { return; } - const container = document.querySelector(".resource-graph-container"); - if (!container || container.clientWidth === 0) { + const container = this.container; + if (container.clientWidth === 0 || container.clientHeight === 0) { return; } - const padding = NODE_COLLIDE_RADIUS; - const xs = this.nodes.map(n => n.x || 0); - const ys = this.nodes.map(n => n.y || 0); - const minX = Math.min(...xs) - padding; - const maxX = Math.max(...xs) + padding; - const minY = Math.min(...ys) - padding; - const maxY = Math.max(...ys) + padding; - - const width = Math.max(maxX - minX, 1); - const height = Math.max(maxY - minY, 1); + // Include labels and selected-node scaling, not just the circle centres. + const bounds = this.nodeElementsG.node().getBBox(); + const padding = 32; + const width = Math.max(bounds.width + padding * 2, 1); + const height = Math.max(bounds.height + padding * 2, 1); // Never scale up past 1. A small graph should sit at natural size in the middle rather than being // blown up to fill the panel. const scale = Math.min(1, container.clientWidth / width, container.clientHeight / height); - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; const transform = d3.zoomIdentity.scale(scale).translate(-centerX, -centerY); this.svg.call(this.zoom.transform, transform); } zoomIn() { + this.userAdjustedView = true; this.svg.transition().call(this.zoom.scaleBy, 1.5); } zoomOut() { + this.userAdjustedView = true; this.svg.transition().call(this.zoom.scaleBy, 2 / 3); } @@ -455,7 +514,7 @@ class ResourceGraph { resize() { // Measure the graph container rather than the whole summary panel. The panel also contains the tabs // row, so measuring it made the drawing area taller than the space the graph actually occupies. - var container = document.querySelector(".resource-graph-container"); + var container = this.container; if (container && container.clientWidth > 0 && container.clientHeight > 0) { var width = container.clientWidth; var height = container.clientHeight; @@ -521,7 +580,7 @@ class ResourceGraph { } updateNodes(newResources) { - const existingNodes = this.nodes || []; // Ensure nodes is initialized + const existingNodes = new Map(this.nodes.map(node => [node.id, node])); const updatedNodes = []; // calculate degree (number of connections) for each resource @@ -539,35 +598,22 @@ class ResourceGraph { }); newResources.forEach(resource => { - const existingNode = existingNodes.find(node => node.id === resource.name); + const node = existingNodes.get(resource.name) || { id: resource.name }; const degree = degreeMap.get(resource.name) || 1; - if (existingNode) { - // Spreading the existing node preserves simulation state, including the fx/fy of a node the - // user has pinned by dragging it. - updatedNodes.push({ - ...existingNode, - label: resource.displayName, - endpointUrl: resource.endpointUrl, - endpointText: resource.endpointText, - resourceIcon: createIcon(resource.resourceIcon), - stateIcon: createIcon(resource.stateIcon), - healthState: resource.healthState, - degree: degree - }); - } else { - // Add new resource - updatedNodes.push({ - id: resource.name, - label: resource.displayName, - endpointUrl: resource.endpointUrl, - endpointText: resource.endpointText, - resourceIcon: createIcon(resource.resourceIcon), - stateIcon: createIcon(resource.stateIcon), - healthState: resource.healthState, - degree: degree - }); - } + // D3 retains this object as the subject for the entire gesture. Replacing it on a health + // update disconnects an active drag from the rendered node and loses the pin on mouse-up. + Object.assign(node, { + label: resource.displayName, + endpointUrl: resource.endpointUrl, + endpointText: resource.endpointText, + resourceIcon: createIcon(resource.resourceIcon), + stateIcon: createIcon(resource.stateIcon), + healthState: resource.healthState, + isAppHost: resource.isAppHost, + degree: degree + }); + updatedNodes.push(node); }); this.nodes = updatedNodes; @@ -596,11 +642,11 @@ class ResourceGraph { var resourceLinks = resource.childNames .filter((childName) => { - return newResources.some(r => r.name === childName); + return healthStateByName.has(childName); }) - .map((childName, index) => { + .map((childName) => { return { - id: `${resource.name}-${childName}`, + id: JSON.stringify([resource.name, childName]), target: childName, source: resource.name, // The link takes the child's rolled up state. Because the child's state already @@ -727,6 +773,7 @@ class ResourceGraph { // hovered (see .resource-menu-cog CSS); it makes the node's interactivity discoverable by opening // the same context menu as right-clicking the node. var cogGroup = newNodesContainer + .filter(n => !n.isAppHost) .append("g") .attr("class", "resource-menu-cog") .attr("id", n => `resource-menu-cog-${n.id}`) @@ -776,6 +823,8 @@ class ResourceGraph { this.nodeElementsG .selectAll(".resource-group") .attr("data-health", n => n.healthState); + this.nodeElements.select(".resource-name text").text(n => trimText(n.label, 30)); + this.nodeElements.select(".resource-name title").text(n => n.label); this.nodeElementsG .selectAll(".resource-group") .select(".resource-menu-cog") @@ -838,6 +887,11 @@ class ResourceGraph { .on('tick', this.onTick); this.simulation.force("link").links(this.links); + if (this.activeDrag) { + this.simulation.stop(); + this.onTick(); + return; + } if (hasStructureChanged) { this.simulation.stop(); @@ -909,6 +963,9 @@ class ResourceGraph { // Prevent default browser context menu. event.preventDefault(); + if (data.isAppHost) { + return; + } await this.openResourceContextMenu(data.id, event.clientX, event.clientY, null, null); }; @@ -951,6 +1008,7 @@ class ResourceGraph { // Wait for method completion. It completes when the context menu is closed. await this.resourcesInterop.invokeMethodAsync('ResourceContextMenu', id, window.innerWidth, window.innerHeight, clientX, clientY, focusElementId); } finally { + this.cancelMenuFocus?.(); this.openContextMenu = false; trigger?.setAttribute("aria-expanded", "false"); @@ -961,6 +1019,9 @@ class ResourceGraph { selectNode = (event) => { var data = event.target.__data__; + if (data.isAppHost) { + return; + } // Always send the clicked on resource to the server. It will clear the selection if the same resource is clicked again. this.resourcesInterop.invokeMethodAsync('SelectResource', data.id); @@ -976,7 +1037,7 @@ class ResourceGraph { changeScale(this, data.id, 1.2); } - this.selectedNode = data; + this.selectedNode = clearSelection ? null : data; function changeScale(self, id, scale) { let match = self.nodeElementsG @@ -1005,9 +1066,6 @@ class ResourceGraph { // Releases a node pinned by dragging so the simulation can lay it out again. unpinNode = (event) => { - // Child elements keep the datum they were appended with, and updateNodes replaces node objects on - // every refresh, so the datum reachable from the event can be a stale copy. Only the id is stable, - // so the live node is looked up from the simulation's own array. var id = event.target.__data__?.id; var node = id ? this.nodes.find(n => n.id === id) : null; if (!node || !node.pinned) { diff --git a/src/Shared/DashboardUrls.cs b/src/Shared/DashboardUrls.cs index 119daaaa924..78543642ad4 100644 --- a/src/Shared/DashboardUrls.cs +++ b/src/Shared/DashboardUrls.cs @@ -150,13 +150,17 @@ public static string TraceDetailUrl(string traceId, string? spanId = null) return url; } - public static string HealthModelUrl(string? entity = null) + public static string HealthModelUrl(string? entity = null, string? view = null) { var url = $"/{HealthModelBasePath}"; if (entity != null) { url = AddQueryString(url, "entity", entity); } + if (view is not null) + { + url = AddQueryString(url, "view", view); + } return url; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs index 766bdf1bb51..0378145d150 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.HealthModel; @@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.JSInterop; using Xunit; namespace Aspire.Dashboard.Components.Tests.Pages; @@ -29,12 +31,11 @@ public void Render_ProjectsAndContainers_ShowsEntityHierarchy() cut.WaitForAssertion(() => { - var text = cut.Markup; - Assert.Contains("Application", text, StringComparison.Ordinal); - Assert.Contains("Services", text, StringComparison.Ordinal); - Assert.Contains("Infrastructure", text, StringComparison.Ordinal); - Assert.Contains("api", text, StringComparison.Ordinal); - Assert.Contains("cache", text, StringComparison.Ordinal); + Assert.Equal(3, cut.FindAll(".health-model-entity").Count); + Assert.Collection(cut.FindAll(".health-model-card-name").Select(e => e.TextContent.Trim()).Order(StringComparer.Ordinal), + name => Assert.Equal("AppHost", name), + name => Assert.Equal("api", name), + name => Assert.Equal("cache", name)); }); } @@ -52,7 +53,7 @@ public void Render_AllResourcesRunning_ShowsHealthyOverallState() } [Fact] - public void Render_FailedContainer_ShowsDegradedOverallState() + public void Render_FailedContainer_ShowsUnhealthyOverallState() { var cut = RenderHealthModelPage( ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running), @@ -61,12 +62,12 @@ public void Render_FailedContainer_ShowsDegradedOverallState() cut.WaitForAssertion(() => { var overview = cut.Find(".health-model-overview-state"); - Assert.Equal(nameof(HealthState.Degraded), overview.TextContent.Trim()); + Assert.Equal(nameof(HealthState.Unhealthy), overview.TextContent.Trim()); }); } [Fact] - public void Render_NoResources_StillShowsLogicalEntities() + public void Render_NoResources_StillShowsAppHostRoot() { var cut = RenderHealthModelPage(); @@ -74,7 +75,7 @@ public void Render_NoResources_StillShowsLogicalEntities() { var overview = cut.Find(".health-model-overview-state"); Assert.Equal(nameof(HealthState.Unknown), overview.TextContent.Trim()); - Assert.Contains("Services", cut.Markup, StringComparison.Ordinal); + Assert.Equal("AppHost", Assert.Single(cut.FindAll(".health-model-card-name")).TextContent.Trim()); }); } @@ -95,7 +96,7 @@ public void Render_SelectedEntity_ShowsSignalsInDetailsPane() ], resourceChannelProvider: Channel.CreateUnbounded>); - ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); + HealthModelSetupHelpers.Setup(this, viewport, dashboardClient); // The selected entity is a query string parameter, so navigate to the deep link rather than // supplying the parameter directly. This also exercises the real deep-link path. @@ -119,6 +120,86 @@ public void Render_SelectedEntity_ShowsSignalsInDetailsPane() } private IRenderedComponent RenderHealthModelPage(params ResourceViewModel[] resources) + => RenderHealthModelPage("Graph", null, resources); + + [Fact] + public async Task SaveFailureKeepsTheDraftAndShowsAnError() + { + var storage = new TestLocalStorage + { + OnSetAsync = (_, _) => throw new JSException("Storage quota exceeded.") + }; + var cut = RenderHealthModelPage("Designer", storage, ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running)); + var graph = cut.FindComponent(); + var api = graph.Instance.Document.Entities.Single(e => e.AspireResourceName == "api"); + await cut.InvokeAsync(() => graph.Instance.MoveEntity(api.Name, 400, 300)); + + cut.FindAll("fluent-button").Single(b => b.TextContent.Trim() == Resources.HealthModel.HealthModelSave).Click(); + + cut.WaitForAssertion(() => + { + Assert.Equal(Resources.HealthModel.HealthModelSaveError, cut.Find("[role='alert']").TextContent.Trim()); + Assert.Equal(Resources.HealthModel.HealthModelUnsaved, cut.Find(".health-model-save-state").TextContent.Trim()); + Assert.NotEqual(api.CanvasPosition, cut.FindComponent().Instance.Document.Entities.Single(e => e.Name == api.Name).CanvasPosition); + }); + } + + [Fact] + public async Task SavePersistsThePortableDocumentAndDiscardRestoresIt() + { + HealthModelDocument? saved = null; + var storage = new TestLocalStorage + { + OnSetAsync = (_, value) => + { + saved = Assert.IsType(value); + return Task.CompletedTask; + } + }; + var cut = RenderHealthModelPage("Designer", storage, ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running)); + var graph = cut.FindComponent(); + var api = graph.Instance.Document.Entities.Single(e => e.AspireResourceName == "api"); + await cut.InvokeAsync(() => graph.Instance.MoveEntity(api.Name, 400, 300)); + cut.FindAll("fluent-button").Single(b => b.TextContent.Trim() == Resources.HealthModel.HealthModelSave).Click(); + cut.WaitForAssertion(() => Assert.NotNull(saved)); + var savedPosition = saved!.Entities.Single(e => e.Name == api.Name).CanvasPosition; + + await cut.InvokeAsync(() => graph.Instance.MoveEntity(api.Name, 800, 600)); + cut.FindAll("fluent-button").Single(b => b.TextContent.Trim() == Resources.HealthModel.HealthModelDiscard).Click(); + + cut.WaitForAssertion(() => + Assert.Equal(savedPosition, cut.FindComponent().Instance.Document.Entities.Single(e => e.Name == api.Name).CanvasPosition)); + } + + [Fact] + public async Task ResourcesDiscoveredAfterInitialLoadRecoverTheirSavedPositions() + { + var channel = Channel.CreateUnbounded>(); + var client = new TestDashboardClient(isEnabled: true, initialResources: [], resourceChannelProvider: () => channel); + var resource = ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running); + var model = AspireHealthModelBuilder.Build([resource]); + var document = HealthModelDocuments.Create(model, client.ApplicationName); + var savedPosition = new HealthModelCanvasPosition(640, 512); + document = document with + { + Entities = [.. document.Entities.Select(e => e.AspireResourceName == "api" ? e with { CanvasPosition = savedPosition } : e)] + }; + var storage = new TestLocalStorage + { + OnGetAsync = key => key.StartsWith("Aspire_HealthModel_v1_", StringComparison.Ordinal) + ? (true, document) : (false, null) + }; + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + HealthModelSetupHelpers.Setup(this, viewport, client, storage); + var cut = RenderComponent(parameters => parameters.AddCascadingValue(viewport)); + + await channel.Writer.WriteAsync([new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, resource)], Xunit.TestContext.Current.CancellationToken); + + cut.WaitForAssertion(() => Assert.Equal(savedPosition, + cut.FindComponent().Instance.Document.Entities.Single(e => e.AspireResourceName == "api").CanvasPosition)); + } + + private IRenderedComponent RenderHealthModelPage(string view, TestLocalStorage? storage, params ResourceViewModel[] resources) { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); var dashboardClient = new TestDashboardClient( @@ -126,7 +207,8 @@ public void Render_SelectedEntity_ShowsSignalsInDetailsPane() initialResources: resources, resourceChannelProvider: Channel.CreateUnbounded>); - ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); + HealthModelSetupHelpers.Setup(this, viewport, dashboardClient, storage); + Services.GetRequiredService().NavigateTo(DashboardUrls.HealthModelUrl(view: view)); return RenderComponent(builder => { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index 79cce984ec0..fb2a2af3c5c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -243,6 +243,7 @@ public void ResourceGraph_MultipleRenders_InitializeOnce() var resourceGraphModule = JSInterop.SetupModule("/js/app-resourcegraph.js"); var initializeGraphInvocationHandler = resourceGraphModule.SetupVoid("initializeResourcesGraph", _ => true); + resourceGraphModule.SetupVoid("disposeResourcesGraph", _ => true).SetVoidResult(); var navigationManager = Services.GetRequiredService(); navigationManager.NavigateTo(DashboardUrls.ResourcesUrl(view: "Graph")); @@ -281,6 +282,7 @@ public async Task ResourceGraphContextMenu_MenuCloseCompletesBrowserCallback() resourceGraphModule.SetupVoid("initializeResourcesGraph", _ => true); resourceGraphModule.SetupVoid("updateResourcesGraph", _ => true); resourceGraphModule.SetupVoid("selectResource", _ => true); + resourceGraphModule.SetupVoid("disposeResourcesGraph", _ => true).SetVoidResult(); var navigationManager = Services.GetRequiredService(); navigationManager.NavigateTo(DashboardUrls.ResourcesUrl(view: "Graph")); @@ -758,6 +760,7 @@ public void GraphView_ExcludesParameters() resourceGraphModule.SetupVoid("initializeResourcesGraph", _ => true); resourceGraphModule.SetupVoid("updateResourcesGraph", _ => true); resourceGraphModule.SetupVoid("updateResourcesGraphSelected", _ => true); + resourceGraphModule.SetupVoid("disposeResourcesGraph", _ => true).SetVoidResult(); var cut = RenderComponent(builder => { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs new file mode 100644 index 00000000000..f9d0e644048 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs @@ -0,0 +1,25 @@ +// 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.Resize; +using Aspire.Dashboard.Model.BrowserStorage; +using Bunit; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal static class HealthModelSetupHelpers +{ + public static void Setup(TestContext context, ViewportInformation viewport, IDashboardClient client, ILocalStorage? storage = null) + { + ResourceSetupHelpers.SetupResourcesPage(context, viewport, client, localStorage: storage); + FluentUISetupHelpers.SetupFluentList(context); + FluentUISetupHelpers.SetupFluentTextField(context); + + var module = context.JSInterop.SetupModule("/js/app-healthmodel.js"); + var graph = module.SetupModule("createHealthModelGraph", _ => true); + graph.SetupVoid("update", _ => true).SetVoidResult(); + graph.SetupVoid("fit", _ => true).SetVoidResult(); + graph.SetupVoid("zoomBy", _ => true).SetVoidResult(); + graph.SetupVoid("dispose", _ => true).SetVoidResult(); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs index 599b743d369..6e584c6fdcc 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs @@ -10,6 +10,7 @@ public sealed class TestLocalStorage : ILocalStorage public Func? OnGetUnprotectedAsync { get; set; } public Action? OnSetUnprotectedAsync { get; set; } public Func? OnGetAsync { get; set; } + public Func? OnSetAsync { get; set; } public Task> GetAsync(string key) { @@ -33,7 +34,7 @@ public Task> GetUnprotectedAsync(string key) public Task SetAsync(string key, T value) { - return Task.CompletedTask; + return OnSetAsync?.Invoke(key, value) ?? Task.CompletedTask; } public Task SetUnprotectedAsync(string key, T value) diff --git a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj index 14c30e26575..82321ea8ff6 100644 --- a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj +++ b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj @@ -19,6 +19,7 @@ + @@ -54,6 +55,8 @@ + + diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs new file mode 100644 index 00000000000..82c9dbc44d0 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs @@ -0,0 +1,184 @@ +// 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.Model.HealthModel; +using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; +using Aspire.TestUtilities; +using Microsoft.Playwright; +using Xunit; +using Strings = Aspire.Dashboard.Resources.HealthModel; + +namespace Aspire.Dashboard.Tests.Integration.Playwright; + +[RequiresFeature(TestFeature.Playwright)] +public class HealthModelTests(ResourceGraphTests.GraphDashboardServerFixture fixture) + : PlaywrightTestsBase(fixture) +{ + [Fact] + public async Task GraphAndEntitiesUseTheSameAppHostTopology() + { + await RunTestAsync(async page => + { + var model = CreateModel(); + await OpenAsync(page, model.Entities.Length); + var paths = await page.Locator(".health-model-edge").EvaluateAllAsync( + "elements => elements.map(e => e.dataset.parent + ':' + e.dataset.child)"); + Assert.Equal(model.Relationships.Select(r => r.ParentEntityName + ":" + r.ChildEntityName).Order(), paths.Order()); + + var root = page.Locator($".health-model-entity[data-entity='{model.Name}']"); + await Assertions.Expect(root).ToHaveAttributeAsync("data-health", "Unhealthy"); + await page.Locator("#Entities").ClickAsync(); + await Assertions.Expect(page.Locator(".health-model-entity-name")).ToHaveCountAsync(model.Entities.Length); + await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "database", Exact = true }).ClickAsync(); + await Assertions.Expect(page.Locator(".health-model-details-layout")).ToContainTextAsync(Strings.HealthModelSignalsSource); + await Assertions.Expect(page.Locator(".health-model-parent-list")).ToContainTextAsync("api"); + }); + } + + [Fact] + public async Task DesignerSavesExactPositionsAcrossReloadAndDefinitionExport() + { + await RunTestAsync(async page => + { + var model = CreateModel(); + await OpenAsync(page, model.Entities.Length); + await page.Locator("#Designer").ClickAsync(); + var entity = model.Entities.Single(e => e.AspireResourceName == "healthy"); + var card = page.Locator($".health-model-entity[data-entity='{entity.Name}']"); + var before = await card.GetAttributeAsync("transform"); + await DragAsync(page, card, 72, 36); + await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); + var moved = await card.GetAttributeAsync("transform"); + Assert.NotEqual(before, moved); + await SaveButton(page).ClickAsync(); + await Assertions.Expect(SaveButton(page)).ToBeDisabledAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Status)).ToContainTextAsync("Model saved"); + + await page.Locator("#Graph").ClickAsync(); + await page.ReloadAsync(); + await Assertions.Expect(card).ToHaveAttributeAsync("transform", moved!); + + var download = await page.RunAndWaitForDownloadAsync(() => + page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelExport, Exact = true }).ClickAsync()); + await using var stream = await download.CreateReadStreamAsync(); + Assert.NotNull(stream); + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + var baseline = HealthModelDocuments.Create(model, "IntegrationTestApplication"); + var document = HealthModelDocuments.Deserialize(json, baseline); + var position = document.Entities.Single(e => e.Name == entity.Name).CanvasPosition; + Assert.Equal(FormattableString.Invariant($"translate({position.X},{position.Y})"), moved); + + await page.Locator("#Designer").ClickAsync(); + await DragAsync(page, card, -48, 56); + await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); + await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelDiscard, Exact = true }).ClickAsync(); + await Assertions.Expect(card).ToHaveAttributeAsync("transform", moved!); + }); + } + + [Fact] + public async Task ImportedPositionsAndImpactAreAppliedAsAnExplicitDraft() + { + await RunTestAsync(async page => + { + var model = CreateModel(); + await OpenAsync(page, model.Entities.Length); + var document = HealthModelDocuments.Create(model, "IntegrationTestApplication"); + var database = document.Entities.Single(e => e.AspireResourceName == "database"); + var imported = document with + { + Entities = [.. document.Entities.Select(e => e.Name == database.Name + ? e with { CanvasPosition = new(888, 688), Impact = EntityImpact.Limited } + : e)] + }; + await page.Locator("input[type='file']").SetInputFilesAsync(new FilePayload + { + Name = "aspire-healthmodel.json", + MimeType = "application/json", + Buffer = Encoding.UTF8.GetBytes(HealthModelDocuments.Serialize(imported)) + }); + + await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); + await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{database.Name}']")) + .ToHaveAttributeAsync("transform", "translate(888,688)"); + await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{model.Name}']")) + .ToHaveAttributeAsync("data-health", "Degraded"); + + await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelDiscard, Exact = true }).ClickAsync(); + await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{model.Name}']")) + .ToHaveAttributeAsync("data-health", "Unhealthy"); + }); + } + + [Fact] + public async Task DesignerEditsPropagationWithoutChangingTheAppHostTopology() + { + await RunTestAsync(async page => + { + var model = CreateModel(); + await OpenAsync(page, model.Entities.Length); + await page.Locator("#Designer").ClickAsync(); + var database = model.Entities.Single(e => e.AspireResourceName == "database"); + await page.Locator($".health-model-entity[data-entity='{database.Name}']").ClickAsync(); + await page.GetByRole(AriaRole.Combobox, new PageGetByRoleOptions { Name = Strings.HealthModelPropertyImpact, Exact = true }).ClickAsync(); + await page.GetByRole(AriaRole.Option, new PageGetByRoleOptions { Name = Strings.HealthModelImpactLimited, Exact = true }).ClickAsync(); + await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelApply, Exact = true }).ClickAsync(); + await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{model.Name}']")) + .ToHaveAttributeAsync("data-health", "Degraded"); + await Assertions.Expect(page.Locator(".health-model-edge")).ToHaveCountAsync(model.Relationships.Length); + await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); + }); + } + + [Fact] + public async Task InvalidImportDoesNotReplaceTheCurrentModel() + { + await RunTestAsync(async page => + { + var model = CreateModel(); + await OpenAsync(page, model.Entities.Length); + var before = await page.Locator(".health-model-entity").EvaluateAllAsync("nodes => nodes.map(n => n.getAttribute('transform'))"); + await page.Locator("input[type='file']").SetInputFilesAsync(new FilePayload + { + Name = "broken.json", + MimeType = "application/json", + Buffer = Encoding.UTF8.GetBytes("{\"schemaVersion\": 999}") + }); + await Assertions.Expect(page.GetByRole(AriaRole.Alert)).ToContainTextAsync(Strings.HealthModelImportError); + Assert.Equal(before, await page.Locator(".health-model-entity").EvaluateAllAsync("nodes => nodes.map(n => n.getAttribute('transform'))")); + }); + } + + private static HealthModelDefinition CreateModel() => AspireHealthModelBuilder.Build( + ResourceGraphTests.GraphDashboardServerFixture.CreateResources(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy)); + + private static ILocator SaveButton(IPage page) => page.GetByRole(AriaRole.Button, + new PageGetByRoleOptions { Name = Strings.HealthModelSave, Exact = true }); + + private static async Task OpenAsync(IPage page, int expectedEntities) + { + await page.SetViewportSizeAsync(1450, 1000); + await page.GotoAsync("/healthmodel"); + await Assertions.Expect(page.Locator(".health-model-entity")).ToHaveCountAsync(expectedEntities); + } + + private static async Task DragAsync(IPage page, ILocator card, float deltaX, float deltaY) + { + var bounds = await card.Locator(".health-model-card").BoundingBoxAsync(); + Assert.NotNull(bounds); + var x = bounds.X + bounds.Width / 2; + var y = bounds.Y + bounds.Height / 2; + await page.Mouse.MoveAsync(x, y); + await page.Mouse.DownAsync(); + try + { + await page.Mouse.MoveAsync(x + deltaX, y + deltaY, new MouseMoveOptions { Steps = 5 }); + } + finally + { + await page.Mouse.UpAsync(); + } + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index d3ec48ca5f6..0c3aadbd40e 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Xunit; namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; @@ -59,9 +60,23 @@ public async ValueTask InitializeAsync() preConfigureBuilder: builder => { builder.Configuration.AddConfiguration(config); - builder.Services.AddSingleton(new MockDashboardClient(Resources)); + // The dashboard registers its selected-run client and repository factory after + // preConfigureBuilder. Override them when the container is built, not before that. + builder.Host.ConfigureContainer((_, services) => + { + var client = new MockDashboardClient(Resources); + services.Replace(ServiceDescriptor.Singleton(client)); + services.Replace(ServiceDescriptor.Singleton(provider => + new MockDashboardRepositoryFactory(provider, client))); + }); }); + using (var scope = DashboardApp.Services.CreateScope()) + { + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } + await DashboardApp.StartAsync(); } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index e37560b5dc0..9da83566ecc 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -8,7 +8,7 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; -public sealed class MockDashboardClient : IDashboardClient +public sealed class MockDashboardClient : IDashboardClient, IResourceRepositoryWriter { public static readonly ResourceViewModel TestResource1 = ModelTestHelpers.CreateResource( resourceName: "TestResource", @@ -80,7 +80,13 @@ public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, throw new NotImplementedException(); } - public ResourceViewModel? GetResource(string resourceName) => null; + public ResourceViewModel? GetResource(string resourceName) => + GetResources().FirstOrDefault(resource => StringComparers.ResourceName.Equals(resource.Name, resourceName)); - public IReadOnlyList GetResources() => _resources ?? []; + public IReadOnlyList GetResources() => _resources ?? [TestResource1]; + + Task IResourceRepositoryWriter.ReplaceResourcesAsync(IReadOnlyList resources) => throw new NotImplementedException(); + Task IResourceRepositoryWriter.ApplyChangesAsync(IReadOnlyList changes) => throw new NotImplementedException(); + Task IResourceRepositoryWriter.MarkConsoleLogsLoadedAsync(string resourceName) => throw new NotImplementedException(); + Task IResourceRepositoryWriter.AddConsoleLogsAsync(string resourceName, IReadOnlyList logLines) => throw new NotImplementedException(); } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardRepositoryFactory.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardRepositoryFactory.cs new file mode 100644 index 00000000000..3e25e46ec05 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardRepositoryFactory.cs @@ -0,0 +1,16 @@ +// 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.Otlp.Storage; + +namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; + +internal sealed class MockDashboardRepositoryFactory(IServiceProvider services, MockDashboardClient client) : IRepositoryFactory +{ + private readonly RepositoryFactory _repositoryFactory = new(services); + + public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => + _repositoryFactory.CreateTelemetryRepository(database); + + public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => client; +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs index 9a57b5eda1d..c30684bda8d 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs @@ -28,9 +28,17 @@ public async ValueTask DisposeAsync() public async Task GoToHomeAndWaitForDataGridLoad(IPage page) { - await page.GotoAsync("/"); + await GoToResourcesAsync(page); await Assertions .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) .ToBeVisibleAsync(); } + + public async Task GoToResourcesAsync(IPage page) + { + // The HTTP root redirect uses the live AppHost client. Client-side navigation instead + // exercises the selected resource client supplied by the browser fixture. + await page.GotoAsync("/traces"); + await page.Locator("a[href='/']").First.ClickAsync(); + } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourceGraphTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourceGraphTests.cs new file mode 100644 index 00000000000..bd7dcb1f5a4 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourceGraphTests.cs @@ -0,0 +1,320 @@ +// 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.Json; +using System.Text.RegularExpressions; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.ResourceGraph; +using Aspire.Dashboard.Resources; +using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; +using Aspire.TestUtilities; +using Aspire.Tests.Shared.DashboardModel; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Playwright; +using Xunit; + +namespace Aspire.Dashboard.Tests.Integration.Playwright; + +// These focused browser checks share an in-process dashboard and need no AppHost or containers. +[RequiresFeature(TestFeature.Playwright)] +public class ResourceGraphTests(ResourceGraphTests.GraphDashboardServerFixture fixture) + : PlaywrightTestsBase(fixture) +{ + [Fact] + public async Task Drag_ContinuesAcrossHealthUpdates_AndStaysPinned() + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + var node = page.Locator(".resource-group[resource-name='healthy']"); + var circle = node.Locator(".resource-node"); + var bounds = await circle.BoundingBoxAsync(); + Assert.NotNull(bounds); + var startX = bounds.X + bounds.Width / 2; + var startY = bounds.Y + bounds.Height / 2; + + await page.Mouse.MoveAsync(startX, startY); + await page.Mouse.DownAsync(); + try + { + await page.Mouse.MoveAsync(startX + 15, startY + 15, new MouseMoveOptions { Steps = 3 }); + await UpdateResourcesAsync(page, HealthStatus.Healthy); + await page.Mouse.MoveAsync(startX + 90, startY + 60, new MouseMoveOptions { Steps = 5 }); + } + finally + { + await page.Mouse.UpAsync(); + } + + await Assertions.Expect(node).ToHaveClassAsync(new Regex(@"\bresource-group-pinned\b")); + var dropped = await circle.BoundingBoxAsync(); + Assert.NotNull(dropped); + Assert.InRange(Math.Abs(dropped.X + dropped.Width / 2 - startX - 90), 0, 2); + Assert.InRange(Math.Abs(dropped.Y + dropped.Height / 2 - startY - 60), 0, 2); + + var position = await node.EvaluateAsync("element => [element.__data__.fx, element.__data__.fy]"); + await UpdateResourcesAsync(page, HealthStatus.Unhealthy); + Assert.Equal(position, await node.EvaluateAsync("element => [element.__data__.fx, element.__data__.fy]")); + await Assertions.Expect(node).ToHaveClassAsync(new Regex(@"\bresource-group-pinned\b")); + }); + } + + [Fact] + public async Task DropOnPinnedNode_ReleasesTheOlderPin_AndSeparatesTheNodes() + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + var older = page.Locator(".resource-group[resource-name='worker-1']"); + var newer = page.Locator(".resource-group[resource-name='worker-2']"); + var original = await older.Locator(".resource-node").BoundingBoxAsync(); + Assert.NotNull(original); + await DragToAsync(page, older, original.X + original.Width / 2 + 30, original.Y + original.Height / 2 + 30); + + var destination = await older.Locator(".resource-node").BoundingBoxAsync(); + Assert.NotNull(destination); + await DragToAsync(page, newer, destination.X + destination.Width / 2, destination.Y + destination.Height / 2); + + await Assertions.Expect(newer).ToHaveClassAsync(new Regex(@"\bresource-group-pinned\b")); + Assert.False(await older.EvaluateAsync("element => !!element.__data__.pinned")); + await page.WaitForFunctionAsync( + """ + () => { + const a = document.querySelector("[resource-name='worker-1']").__data__; + const b = document.querySelector("[resource-name='worker-2']").__data__; + return Math.hypot(a.x - b.x, a.y - b.y) >= 180; + } + """); + }); + } + + [Theory] + [InlineData("database", "Unhealthy")] + [InlineData("cache", "Degraded")] + [InlineData("healthy", "Healthy")] + public async Task HealthColors_RemainVisibleDuringHoverAndSelection(string resourceName, string state) + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + var node = page.Locator($".resource-group[resource-name='{resourceName}']"); + var circle = node.Locator(".resource-node"); + await Assertions.Expect(node).ToHaveAttributeAsync("data-health", state); + var fill = await circle.EvaluateAsync("element => getComputedStyle(element).fill"); + + await node.Locator(".resource-scale").HoverAsync(); + await Assertions.Expect(circle).ToHaveCSSAsync("fill", fill); + await Assertions.Expect(page.Locator($".links line[data-health='{state}']").First) + .ToHaveCSSAsync("stroke-dasharray", "none"); + + await node.Locator(".resource-scale").ClickAsync(); + await Assertions.Expect(node).ToHaveClassAsync(new Regex(@"\bresource-group-selected\b")); + await Assertions.Expect(circle).ToHaveCSSAsync("fill", fill); + }); + } + + [Fact] + public async Task Reset_AfterZoomAndPan_FitsTheWholeGraph() + { + await RunTestAsync(async page => + { + await page.SetViewportSizeAsync(700, 650); + await OpenGraphAsync(page); + var node = page.Locator(".resource-group[resource-name='healthy']"); + var bounds = await node.Locator(".resource-node").BoundingBoxAsync(); + Assert.NotNull(bounds); + await DragToAsync(page, node, bounds.X + bounds.Width / 2 + 50, bounds.Y + bounds.Height / 2 + 40); + await Assertions.Expect(node).ToHaveClassAsync(new Regex(@"\bresource-group-pinned\b")); + await page.Locator(".graph-zoom-in").ClickAsync(); + await page.Mouse.MoveAsync(300, 200); + await page.Mouse.WheelAsync(0, -500); + await page.Locator(".graph-reset").ClickAsync(); + + await AssertGraphFitsAsync(page); + Assert.Empty(await page.Locator(".resource-group-pinned").AllAsync()); + }); + } + + [Fact] + public async Task ZoomButton_PreservesFramingWhenContainerResizes() + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + var svg = page.Locator(".resource-graph"); + var initialScale = await svg.EvaluateAsync("element => element.__zoom.k"); + await page.Locator(".graph-zoom-in").ClickAsync(); + await page.WaitForFunctionAsync( + "scale => document.querySelector('.resource-graph').__zoom.k >= scale * 1.49", initialScale); + + await page.SetViewportSizeAsync(1000, 740); + await page.WaitForFunctionAsync( + "() => document.querySelector('.resource-graph').viewBox.baseVal.width === document.querySelector('.resource-graph-container').clientWidth"); + var scaleAfterResize = await svg.EvaluateAsync("element => element.__zoom.k"); + Assert.InRange(scaleAfterResize, initialScale * 1.49, initialScale * 1.51); + }); + } + + [Fact] + public async Task AppHost_HasNoResourceCommands() + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + var appHost = page.Locator(".resource-group[resource-name='$apphost']"); + await Assertions.Expect(appHost).ToBeVisibleAsync(); + await Assertions.Expect(appHost.Locator(".resource-menu-cog")).ToHaveCountAsync(0); + }); + } + + [Fact] + public async Task ResourceActions_RemainKeyboardAccessibleAfterHealthUpdate() + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + await UpdateResourcesAsync(page, HealthStatus.Healthy); + var cog = page.Locator(".resource-group[resource-name='healthy'] .resource-menu-cog"); + await cog.FocusAsync(); + await page.Keyboard.PressAsync("Enter"); + + var menu = page.GetByRole(AriaRole.Menu, new PageGetByRoleOptions { Name = "healthy", Exact = true }); + await Assertions.Expect(menu).ToBeVisibleAsync(); + await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "true"); + await AsyncTestHelpers.AssertIsTrueRetryAsync( + async () => await menu.EvaluateAsync("element => element.contains(document.activeElement)"), + "The resource menu should receive keyboard focus before Escape is sent."); + + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(menu).ToBeHiddenAsync(); + await Assertions.Expect(cog).ToBeFocusedAsync(); + }); + } + + [Fact] + public async Task ReenteringGraph_DoesNotAccumulateGraphElements() + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + await page.Locator("a[href='/traces']").First.ClickAsync(); + await Assertions.Expect(page.Locator(".resource-graph")).ToHaveCountAsync(0); + await page.Locator("a[href='/']").First.ClickAsync(); + await page.Locator("#tab-Graph").ClickAsync(); + await Assertions.Expect(page.Locator(".resource-graph > g")).ToHaveCountAsync(1); + await Assertions.Expect(page.Locator(".resource-graph > defs")).ToHaveCountAsync(1); + await Assertions.Expect(page.Locator(".resource-group")).ToHaveCountAsync(GraphDashboardServerFixture.CreateResources(HealthStatus.Unhealthy).Count + 1); + }); + } + + [Fact] + public async Task HyphenatedResourceNames_KeepDistinctRelationshipLines() + { + await RunTestAsync(async page => + { + await OpenGraphAsync(page); + await UpdateResourcesAsync(page, + [ + ModelTestHelpers.CreateResource("api-cache", state: KnownResourceState.Running, + relationships: [new("db", KnownRelationshipTypes.Reference)]), + ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, + relationships: [new("cache-db", KnownRelationshipTypes.Reference)]), + ModelTestHelpers.CreateResource("cache-db", state: KnownResourceState.Running), + ModelTestHelpers.CreateResource("db", state: KnownResourceState.Running) + ]); + await Assertions.Expect(page.Locator(".links line")).ToHaveCountAsync(4); + var keys = await page.Locator(".links line").EvaluateAllAsync( + "elements => elements.map(element => element.__data__.id)"); + + Assert.Equal(4, keys.Length); + Assert.Equal(keys.Length, keys.Distinct(StringComparer.Ordinal).Count()); + }); + } + + private async Task OpenGraphAsync(IPage page) + { + page.PageError += (_, error) => TestContext.Current.TestOutputHelper?.WriteLine(error); + await PlaywrightFixture.GoToResourcesAsync(page); + await page.Locator("#tab-Graph").ClickAsync(); + await Assertions.Expect(page.Locator(".resource-group")) + .ToHaveCountAsync(GraphDashboardServerFixture.CreateResources(HealthStatus.Unhealthy).Count + 1); + await page.WaitForFunctionAsync( + """ + () => [...document.querySelectorAll('.resource-group')].every(element => { + const node = element.__data__; + return Number.isFinite(node.x) && Number.isFinite(node.y) && + Math.abs(node.vx || 0) < 0.05 && Math.abs(node.vy || 0) < 0.05; + }) + """); + } + + private static Task AssertGraphFitsAsync(IPage page) + { + return page.WaitForFunctionAsync( + """ + () => { + const bounds = document.querySelector('.resource-graph-container').getBoundingClientRect(); + return [...document.querySelectorAll('.resource-group')].every(element => { + const node = element.getBoundingClientRect(); + return node.left >= bounds.left - 1 && node.right <= bounds.right + 1 && + node.top >= bounds.top - 1 && node.bottom <= bounds.bottom + 1; + }); + } + """); + } + + private static async Task DragToAsync(IPage page, ILocator node, float x, float y) + { + var bounds = await node.Locator(".resource-node").BoundingBoxAsync(); + Assert.NotNull(bounds); + await page.Mouse.MoveAsync(bounds.X + bounds.Width / 2, bounds.Y + bounds.Height / 2); + await page.Mouse.DownAsync(); + try + { + await page.Mouse.MoveAsync(x, y, new MouseMoveOptions { Steps = 5 }); + } + finally + { + await page.Mouse.UpAsync(); + } + } + + private static Task UpdateResourcesAsync(IPage page, HealthStatus databaseHealth) => + UpdateResourcesAsync(page, GraphDashboardServerFixture.CreateResources(databaseHealth)); + + private static async Task UpdateResourcesAsync(IPage page, IReadOnlyList graphResources) + { + var resources = graphResources + .OrderBy(r => r.ResourceType).ThenBy(r => r.Name).ToList(); + var dtos = ResourceGraphMapper.MapResources( + resources, resources.ToDictionary(r => r.Name), new TestStringLocalizer(), + showHiddenResources: false, new IconResolver(NullLogger.Instance), "IntegrationTestApplication"); + var json = JsonSerializer.Serialize(dtos, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + // Import the page's existing module instance to exercise an update between real pointer events. + await page.EvaluateAsync( + """ + async json => { + const graph = await import('/js/app-resourcegraph.js'); + graph.updateResourcesGraph(JSON.parse(json)); + } + """, json); + } + + public sealed class GraphDashboardServerFixture : DashboardServerFixture + { + protected override IReadOnlyList Resources => CreateResources(HealthStatus.Unhealthy); + + internal static IReadOnlyList CreateResources(HealthStatus databaseHealth) => + [ + ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, + relationships: [new("database", KnownRelationshipTypes.Reference), new("cache", KnownRelationshipTypes.Reference), new("healthy", KnownRelationshipTypes.Reference)]), + ModelTestHelpers.CreateResource("database", state: KnownResourceState.Running, reportHealthStatus: databaseHealth), + ModelTestHelpers.CreateResource("cache", state: KnownResourceState.Running, reportHealthStatus: HealthStatus.Degraded), + ModelTestHelpers.CreateResource("healthy", state: KnownResourceState.Running), + .. Enumerable.Range(1, 8).Select(i => ModelTestHelpers.CreateResource($"worker-{i}", state: KnownResourceState.Running)) + ]; + } +} diff --git a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs index fb688716738..dad53e90106 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs @@ -12,22 +12,16 @@ namespace Aspire.Dashboard.Tests.Model; public class AspireHealthModelBuilderTests { [Fact] - public void Build_NoResources_StillProducesLogicalEntities() + public void Build_NoResources_StillProducesAppHostRoot() { var definition = AspireHealthModelBuilder.Build([]); - Assert.Collection(definition.Entities, - e => Assert.Equal(AspireHealthModelBuilder.RootEntityName, e.Name), - e => Assert.Equal(AspireHealthModelBuilder.ServicesEntityName, e.Name), - e => Assert.Equal(AspireHealthModelBuilder.InfrastructureEntityName, e.Name)); - - Assert.Collection(definition.Relationships, - r => Assert.Equal(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.ServicesEntityName), r), - r => Assert.Equal(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.InfrastructureEntityName), r)); + Assert.Equal(AspireHealthModelBuilder.RootEntityName, Assert.Single(definition.Entities).Name); + Assert.Empty(definition.Relationships); } [Fact] - public void Build_ProjectsAndContainers_AreGroupedUnderDifferentParents() + public void Build_IndependentResources_AreChildrenOfTheAppHost() { var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running); @@ -37,8 +31,9 @@ public void Build_ProjectsAndContainers_AreGroupedUnderDifferentParents() var projectEntityName = AspireHealthModelBuilder.GetEntityName(project); var containerEntityName = AspireHealthModelBuilder.GetEntityName(container); - Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.ServicesEntityName, projectEntityName), definition.Relationships); - Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.InfrastructureEntityName, containerEntityName), definition.Relationships); + Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, projectEntityName), definition.Relationships); + Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, containerEntityName), definition.Relationships); + Assert.All(definition.Entities, entity => Assert.Equal(EntityImpact.Standard, entity.Impact)); } [Fact] @@ -49,14 +44,11 @@ public void Build_ResourceWithoutRuntimeHealth_IsExcluded() var definition = AspireHealthModelBuilder.Build([parameter, connectionString]); - Assert.Collection(definition.Entities, - e => Assert.Equal(AspireHealthModelBuilder.RootEntityName, e.Name), - e => Assert.Equal(AspireHealthModelBuilder.ServicesEntityName, e.Name), - e => Assert.Equal(AspireHealthModelBuilder.InfrastructureEntityName, e.Name)); + Assert.Equal(AspireHealthModelBuilder.RootEntityName, Assert.Single(definition.Entities).Name); } [Fact] - public void Build_CustomResourceType_IsGroupedUnderServices() + public void Build_CustomResourceType_IsIncluded() { // Custom resource types can carry health checks, so they must appear in the model rather than being // dropped because they are not a known type. @@ -65,19 +57,19 @@ public void Build_CustomResourceType_IsGroupedUnderServices() var definition = AspireHealthModelBuilder.Build([custom]); Assert.Contains( - new HealthModelRelationship(AspireHealthModelBuilder.ServicesEntityName, AspireHealthModelBuilder.GetEntityName(custom)), + new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.GetEntityName(custom)), definition.Relationships); } [Fact] - public void Build_ExternalService_IsGroupedUnderInfrastructure() + public void Build_ExternalService_IsIncluded() { var external = ModelTestHelpers.CreateResource(resourceName: "api-gateway", resourceType: KnownResourceTypes.ExternalService, state: KnownResourceState.Running); var definition = AspireHealthModelBuilder.Build([external]); Assert.Contains( - new HealthModelRelationship(AspireHealthModelBuilder.InfrastructureEntityName, AspireHealthModelBuilder.GetEntityName(external)), + new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.GetEntityName(external)), definition.Relationships); } @@ -88,10 +80,7 @@ public void Build_HiddenResource_IsExcluded() var definition = AspireHealthModelBuilder.Build([hidden]); - Assert.Collection(definition.Entities, - e => Assert.Equal(AspireHealthModelBuilder.RootEntityName, e.Name), - e => Assert.Equal(AspireHealthModelBuilder.ServicesEntityName, e.Name), - e => Assert.Equal(AspireHealthModelBuilder.InfrastructureEntityName, e.Name)); + Assert.Equal(AspireHealthModelBuilder.RootEntityName, Assert.Single(definition.Entities).Name); } [Fact] @@ -172,19 +161,53 @@ public void MapHealthStatus_MapsHealthCheckResults(HealthStatus status, HealthSt } [Fact] - public void BuildAndEvaluate_UnhealthyContainer_DegradesApplicationRatherThanFailingIt() + public void BuildAndEvaluate_UnhealthyContainer_UsesStandardImpactUnlessConfigured() { - // The container group itself reports unhealthy, but infrastructure has limited impact so the - // application only sees degraded. This is the behaviour the sample model exists to demonstrate. var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.FailedToStart); var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project, container])); - Assert.Equal(HealthState.Degraded, snapshot.State); + Assert.Equal(HealthState.Unhealthy, snapshot.State); + } + + [Fact] + public void Build_UsesTheAppHostDependencyChain_NotResourceTypeBuckets() + { + var api = ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, + relationships: [new("server", KnownRelationshipTypes.Reference)]); + var server = ModelTestHelpers.CreateResource("server", state: KnownResourceState.Running); + var database = ModelTestHelpers.CreateResource("database", state: KnownResourceState.Running, + relationships: [new("server", KnownRelationshipTypes.Parent)]); + var model = AspireHealthModelBuilder.Build([database, api, server]); + var expected = new[] + { + new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.GetEntityName(api)), + new HealthModelRelationship(AspireHealthModelBuilder.GetEntityName(api), AspireHealthModelBuilder.GetEntityName(server)), + new HealthModelRelationship(AspireHealthModelBuilder.GetEntityName(server), AspireHealthModelBuilder.GetEntityName(database)) + }; + + Assert.Equal(expected.OrderBy(r => r.ParentEntityName).ThenBy(r => r.ChildEntityName), model.Relationships); + } - var infrastructure = Assert.Single(snapshot.AllNodes, n => n.Name == AspireHealthModelBuilder.InfrastructureEntityName); - Assert.Equal(HealthState.Unhealthy, infrastructure.State); + [Theory] + [InlineData("api_v1")] + [InlineData("a.b")] + [InlineData("应用 service")] + public void EntityNames_AreStableAndAzureCompatible(string displayName) + { + var resource = ModelTestHelpers.CreateResource("runtime-suffix", displayName: displayName); + var name = AspireHealthModelBuilder.GetEntityName(resource); + Assert.Matches("^[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]$", name); + Assert.Equal(name, AspireHealthModelBuilder.GetEntityName(ModelTestHelpers.CreateResource("another-runtime-suffix", displayName: displayName))); + } + + [Fact] + public void EntityNames_DoNotCollideWhenDisplayNamesNormalizeToTheSameSlug() + { + var a = ModelTestHelpers.CreateResource(displayName: "api.v1"); + var b = ModelTestHelpers.CreateResource(displayName: "api_v1"); + Assert.NotEqual(AspireHealthModelBuilder.GetEntityName(a), AspireHealthModelBuilder.GetEntityName(b)); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs b/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs new file mode 100644 index 00000000000..79e3e40dbbe --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs @@ -0,0 +1,169 @@ +// 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.Json; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.HealthModel; +using Aspire.Tests.Shared.DashboardModel; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using VerifyXunit; +using Xunit; + +namespace Aspire.Dashboard.Tests.Model; + +public class HealthModelDocumentTests +{ + [Fact] + public Task ExportedDocumentContainsOnlyDefinition() + { + var definition = CreateModel("api-random"); + var document = HealthModelDocuments.Create(definition, "SampleApp"); + + return Verifier.Verify(HealthModelDocuments.Serialize(document), "json").UseDirectory("Snapshots"); + } + + [Fact] + public void DocumentRoundTripPreservesExactPositionsAndPropagation() + { + var model = CreateModel("api-runtime-one"); + var original = HealthModelDocuments.Create(model, "SampleApp"); + var api = original.Entities.Single(e => e.AspireResourceName == "api"); + var changed = api with + { + CanvasPosition = new HealthModelCanvasPosition(-712.25, 318.5), + Impact = EntityImpact.Limited, + HealthObjective = 99.9, + Dependencies = new DependenciesAggregation + { + AggregationType = DependenciesAggregationType.MinHealthy, + Unit = AggregationUnit.Percentage, + UnhealthyThreshold = 40, + DegradedThreshold = 80 + } + }; + original = original with { Entities = [.. original.Entities.Select(e => e.Name == api.Name ? changed : e)] }; + + var imported = HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(original), original); + var restarted = HealthModelDocuments.Reconcile(imported, CreateModel("api-runtime-two")); + var rebound = restarted.Entities.Single(e => e.AspireResourceName == "api"); + + Assert.Equal(changed.CanvasPosition, rebound.CanvasPosition); + Assert.Equal(changed.Impact, rebound.Impact); + Assert.Equal(changed.HealthObjective, rebound.HealthObjective); + Assert.Equal(changed.Dependencies, rebound.Dependencies); + Assert.Equal(original.Relationships, restarted.Relationships); + Assert.Equal(changed.Name, rebound.Name); + } + + [Fact] + public void ImportRejectsAnotherApplicationOrChangedTopology() + { + var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); + var otherApp = current with { ApplicationName = "OtherApp" }; + var otherTopology = current with { Relationships = [] }; + + Assert.Throws(() => HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(otherApp), current)); + Assert.Throws(() => HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(otherTopology), current)); + } + + [Fact] + public void ImportRejectsUnknownPropertiesRatherThanDroppingData() + { + var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); + var json = HealthModelDocuments.Serialize(current).Replace("\"schemaVersion\": 1,", "\"schemaVersion\": 1, \"unsupportedSetting\": true,"); + + Assert.Throws(() => HealthModelDocuments.Deserialize(json, current)); + } + + [Fact] + public void ImportRequiresAnExplicitSchemaVersion() + { + var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); + var json = HealthModelDocuments.Serialize(current).Replace("\"schemaVersion\": 1,", string.Empty); + Assert.Throws(() => HealthModelDocuments.Deserialize(json, current)); + } + + [Fact] + public void ImportCannotReplaceTheLiveSignalBindings() + { + var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); + var edited = current with { Entities = [.. current.Entities.Select(e => e with { LocalSignals = [] })] }; + Assert.Throws(() => HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(edited), current)); + } + + [Theory] + [InlineData(double.NaN, 0)] + [InlineData(0, double.PositiveInfinity)] + [InlineData(1000001, 0)] + public void InvalidCoordinatesAreRejected(double x, double y) + { + var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); + var invalid = current with + { + Entities = [.. current.Entities.Select((e, i) => i == 0 ? e with { CanvasPosition = new(x, y) } : e)] + }; + + Assert.Throws(() => HealthModelDocuments.Validate(invalid, "SampleApp")); + } + + [Theory] + [InlineData(DependenciesAggregationType.MaxNotHealthy, 20, 10)] + [InlineData(DependenciesAggregationType.MinHealthy, 10, 20)] + public void ThresholdOrderingIsValidated(DependenciesAggregationType type, double degraded, double unhealthy) + { + var aggregation = new DependenciesAggregation + { + AggregationType = type, + Unit = AggregationUnit.Percentage, + DegradedThreshold = degraded, + UnhealthyThreshold = unhealthy + }; + Assert.Throws(() => HealthModelDocuments.ValidateAggregation(aggregation)); + } + + [Fact] + public void ArrangeIsIndependentOfResourceOrderAndDoesNotOverlap() + { + var model = CreateModel("api"); + var original = HealthModelLayout.Arrange(model); + var reordered = HealthModelLayout.Arrange(model with + { + Entities = [.. model.Entities.Reverse()], + Relationships = [.. model.Relationships.Reverse()] + }); + + Assert.Equal(original.OrderBy(p => p.Key), reordered.OrderBy(p => p.Key)); + foreach (var first in original) + { + foreach (var second in original.Where(pair => pair.Key != first.Key)) + { + Assert.True(Math.Abs(first.Value.X - second.Value.X) >= HealthModelLayout.CardWidth || + Math.Abs(first.Value.Y - second.Value.Y) >= HealthModelLayout.CardHeight); + } + } + } + + [Fact] + public void DropOnAnotherEntityFindsAFreePositionWithoutMovingTheOtherEntity() + { + var document = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); + var root = document.Entities[0]; + var api = document.Entities.Single(e => e.AspireResourceName == "api"); + + var position = HealthModelLayout.Place(document, api.Name, root.CanvasPosition); + + Assert.NotEqual(root.CanvasPosition, position); + Assert.True(Math.Abs(position.X - root.CanvasPosition.X) >= HealthModelLayout.CardWidth || + Math.Abs(position.Y - root.CanvasPosition.Y) >= HealthModelLayout.CardHeight); + Assert.Equal(root, document.Entities[0]); + } + + private static HealthModelDefinition CreateModel(string runtimeName) => AspireHealthModelBuilder.Build( + [ + ModelTestHelpers.CreateResource(runtimeName, displayName: "api", state: KnownResourceState.Running, + relationships: [new("database", KnownRelationshipTypes.Reference)], + environment: [new EnvironmentVariableViewModel("SECRET", "not-for-export", fromSpec: true)]), + ModelTestHelpers.CreateResource("database", displayName: "database", state: KnownResourceState.Running, + healthReports: [new("ready", HealthStatus.Unhealthy, "private measurement description", "private exception text")]) + ]); +} diff --git a/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs b/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs index 10c00300986..bad6312bda4 100644 --- a/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs @@ -269,18 +269,29 @@ public void Evaluate_FlattensNodesInDepthFirstOrderWithDepth() } [Fact] - public void Evaluate_CyclicRelationships_DoesNotRecurseForever() + public void Evaluate_CyclicRelationships_ReportsInvalidHierarchy() { var definition = CreateModel( [Entity("root"), Entity("a"), Entity("b")], [("root", "a"), ("a", "b"), ("b", "a")]); + Assert.Throws(() => HealthModelEvaluator.Evaluate(definition)); + } + + [Fact] + public void Evaluate_SharedDependency_ProducesOneNodeAndKeepsBothRelationships() + { + var definition = CreateModel( + [Entity("root"), Entity("a"), Entity("b"), Entity("shared", signals: [Signal("s", HealthState.Degraded)])], + [("root", "a"), ("root", "b"), ("a", "shared"), ("b", "shared")]); + var snapshot = HealthModelEvaluator.Evaluate(definition); - Assert.Collection(snapshot.AllNodes, - n => Assert.Equal("root", n.Name), - n => Assert.Equal("a", n.Name), - n => Assert.Equal("b", n.Name)); + Assert.Equal(4, snapshot.AllNodes.Length); + Assert.Equal(HealthState.Degraded, snapshot.State); + var shared = Assert.Single(snapshot.AllNodes, n => n.Name == "shared"); + Assert.Same(shared, Assert.Single(snapshot.AllNodes.Single(n => n.Name == "a").Children)); + Assert.Same(shared, Assert.Single(snapshot.AllNodes.Single(n => n.Name == "b").Children)); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs index 0342f983fd6..3e785fc6cff 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphHealthTests.cs @@ -71,6 +71,22 @@ public void BuildEdges_HiddenDependency_ExcludedUnlessShown() Assert.Single(ResourceGraphHealth.BuildEdges([api, hidden], showHiddenResources: true))); } + [Theory] + [InlineData(KnownRelationshipTypes.Reference)] + [InlineData(KnownRelationshipTypes.Parent)] + public void BuildEdges_HiddenSource_ExcludedUnlessShown(string relationshipType) + { + var visible = CreateResource("visible"); + var hidden = CreateResource("hidden", hidden: true, relationships: [new RelationshipViewModel("visible", relationshipType)]); + + Assert.Empty(ResourceGraphHealth.BuildEdges([visible, hidden], showHiddenResources: false)); + + var expected = relationshipType == KnownRelationshipTypes.Parent + ? new ResourceGraphEdge("visible", "hidden") + : new ResourceGraphEdge("hidden", "visible"); + Assert.Equal(expected, Assert.Single(ResourceGraphHealth.BuildEdges([visible, hidden], showHiddenResources: true))); + } + [Fact] public void BuildEdges_DuplicateRelationships_ProduceASingleEdge() { @@ -223,6 +239,62 @@ public void ComputeEffectiveStates_CyclicDependencies_DoNotRecurseForever() Assert.Equal(HealthState.Unhealthy, states["b"]); } + [Theory] + [InlineData(HealthStatus.Unhealthy, false, false)] + [InlineData(HealthStatus.Unhealthy, false, true)] + [InlineData(HealthStatus.Unhealthy, true, false)] + [InlineData(HealthStatus.Unhealthy, true, true)] + [InlineData(HealthStatus.Degraded, false, false)] + [InlineData(HealthStatus.Degraded, false, true)] + [InlineData(HealthStatus.Degraded, true, false)] + [InlineData(HealthStatus.Degraded, true, true)] + public void ComputeEffectiveStates_CycleInheritsExternalDependency_RegardlessOfTraversalOrder( + HealthStatus health, bool reverseResources, bool reverseEdges) + { + var a = CreateResource("a"); + var b = CreateResource("b"); + var leaf = CreateResource("leaf", health: health); + var dependent = CreateResource("dependent"); + ResourceViewModel[] resources = [a, b, leaf, dependent]; + ResourceGraphEdge[] edges = [new("a", "b"), new("b", "a"), new("a", "leaf"), new("dependent", "b")]; + if (reverseResources) + { + Array.Reverse(resources); + } + if (reverseEdges) + { + Array.Reverse(edges); + } + + var states = ResourceGraphHealth.ComputeEffectiveStates(resources, [.. edges]); + var expected = AspireHealthModelBuilder.MapHealthStatus(health); + + Assert.Equal(4, states.Count); + Assert.All(states.Values, state => Assert.Equal(expected, state)); + + var recovered = ResourceGraphHealth.ComputeEffectiveStates( + [a, b, CreateResource("leaf"), dependent], [.. edges]); + + Assert.All(recovered.Values, state => Assert.Equal(HealthState.Healthy, state)); + } + + [Fact] + public void ComputeEffectiveStates_LongDependencyChain_DoesNotRequireRecursion() + { + const int count = 5000; + var resources = Enumerable.Range(0, count) + .Select(i => CreateResource($"resource-{i}", health: i == count - 1 ? HealthStatus.Unhealthy : HealthStatus.Healthy)) + .ToArray(); + var edges = Enumerable.Range(0, count - 1) + .Select(i => new ResourceGraphEdge(resources[i].Name, resources[i + 1].Name)) + .ToImmutableArray(); + + var states = ResourceGraphHealth.ComputeEffectiveStates(resources, edges); + + Assert.Equal(count, states.Count); + Assert.All(states.Values, state => Assert.Equal(HealthState.Unhealthy, state)); + } + [Fact] public void ComputeEffectiveStates_DiamondDependency_ResolvesSharedLeafOnce() { diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs index 9f1b9b00b85..26e10118d58 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs @@ -207,6 +207,8 @@ public void MapResources_AddsAppHostRootThatParentsEveryTopLevelResource() var appHost = dtos[0]; Assert.Equal(ResourceGraphMapper.AppHostEntityName, appHost.Name); + Assert.True(appHost.IsAppHost); + Assert.All(dtos.Skip(1), dto => Assert.False(dto.IsAppHost)); Assert.Equal("TestApp", appHost.DisplayName); Assert.Collection(appHost.ChildNames, n => Assert.Equal("api", n), diff --git a/tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json b/tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json new file mode 100644 index 00000000000..6ce5fc4ac69 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json @@ -0,0 +1,91 @@ +{ + "schemaVersion": 1, + "name": "aspire-app-health", + "applicationName": "SampleApp", + "entities": [ + { + "name": "aspire-app-health", + "displayName": "AppHost", + "aspireResourceName": null, + "replicaIndex": null, + "canvasPosition": { + "x": 0, + "y": 0 + }, + "impact": "Standard", + "healthObjective": null, + "dependencies": { + "aggregationType": "WorstOf", + "degradedThreshold": null, + "unhealthyThreshold": null, + "unit": "Absolute", + "ignoreUnknown": true + }, + "localSignals": [] + }, + { + "name": "resource-api-4f52125c3d5b162d", + "displayName": "api", + "aspireResourceName": "api", + "replicaIndex": 0, + "canvasPosition": { + "x": 0, + "y": 180 + }, + "impact": "Standard", + "healthObjective": null, + "dependencies": { + "aggregationType": "WorstOf", + "degradedThreshold": null, + "unhealthyThreshold": null, + "unit": "Absolute", + "ignoreUnknown": true + }, + "localSignals": [ + { + "name": "resource-state", + "kind": "External" + } + ] + }, + { + "name": "resource-database-cbf60de488b72f16", + "displayName": "database", + "aspireResourceName": "database", + "replicaIndex": 0, + "canvasPosition": { + "x": 0, + "y": 360 + }, + "impact": "Standard", + "healthObjective": null, + "dependencies": { + "aggregationType": "WorstOf", + "degradedThreshold": null, + "unhealthyThreshold": null, + "unit": "Absolute", + "ignoreUnknown": true + }, + "localSignals": [ + { + "name": "resource-state", + "kind": "External" + }, + { + "name": "ready", + "kind": "External" + } + ] + } + ], + "relationships": [ + { + "parentEntityName": "aspire-app-health", + "childEntityName": "resource-api-4f52125c3d5b162d" + }, + { + "parentEntityName": "resource-api-4f52125c3d5b162d", + "childEntityName": "resource-database-cbf60de488b72f16" + } + ] +} \ No newline at end of file From 3dec255fa2f07a4cd9be373ecb1c4a34d9e8c53e Mon Sep 17 00:00:00 2001 From: James Gould Date: Sun, 13 Sep 2026 15:26:33 +0100 Subject: [PATCH 26/28] experimental aspire publishing --- Aspire.slnx | 2 + .../HealthModel.Metrics.csproj | 13 + .../HealthModel.Metrics/HealthModelMetrics.cs | 63 ++++ .../HealthModel.Metrics/Program.cs | 21 ++ .../HealthModelSandbox.AppHost/AppHost.cs | 85 +++-- .../HealthModelSandbox.AppHost.csproj | 4 + .../aspire-healthmodel.json | 204 ++++++++++++ playground/HealthModel/HealthModelScenario.cs | 65 ++++ playground/HealthModel/README.md | 132 +++++--- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 7 + .../Model/HealthModel/HealthModelDocument.cs | 143 +-------- .../Model/HealthModel/HealthModelEntity.cs | 13 - .../Model/HealthModel/HealthModelSignal.cs | 23 -- .../Resources/HealthModel.Designer.cs | 4 +- .../Resources/HealthModel.resx | 4 +- .../Resources/xlf/HealthModel.cs.xlf | 8 +- .../Resources/xlf/HealthModel.de.xlf | 8 +- .../Resources/xlf/HealthModel.es.xlf | 8 +- .../Resources/xlf/HealthModel.fr.xlf | 8 +- .../Resources/xlf/HealthModel.it.xlf | 8 +- .../Resources/xlf/HealthModel.ja.xlf | 8 +- .../Resources/xlf/HealthModel.ko.xlf | 8 +- .../Resources/xlf/HealthModel.pl.xlf | 8 +- .../Resources/xlf/HealthModel.pt-BR.xlf | 8 +- .../Resources/xlf/HealthModel.ru.xlf | 8 +- .../Resources/xlf/HealthModel.tr.xlf | 8 +- .../Resources/xlf/HealthModel.zh-Hans.xlf | 8 +- .../Resources/xlf/HealthModel.zh-Hant.xlf | 8 +- .../Aspire.Hosting.Azure.HealthModels.csproj | 19 ++ .../AzureHealthModelCollectorExtensions.cs | 97 ++++++ .../AzureHealthModelExtensions.cs | 49 +++ .../AzureHealthModelResource.cs | 83 +++++ .../HealthModelBicepGenerator.cs | 299 ++++++++++++++++++ .../HealthModelBindingValidator.cs | 75 +++++ .../README.md | 144 +++++++++ .../HealthModels}/DependenciesAggregation.cs | 6 +- .../HealthModels}/EntityImpact.cs | 2 +- .../HealthModels/HealthModelContract.cs | 171 ++++++++++ .../HealthModels/HealthModelDocument.cs | 86 +++++ .../HealthModels}/HealthState.cs | 10 +- src/Shared/HealthModels/SignalKind.cs | 27 ++ .../Aspire.Dashboard.Components.Tests.csproj | 1 + .../Aspire.Dashboard.Tests.csproj | 4 + .../Model/HealthModelDocumentTests.cs | 24 ++ .../Model/ResourceGraphMapperTests.cs | 1 - .../Aspire.Hosting.Azure.Tests.csproj | 5 + .../AzureHealthModelTests.cs | 215 +++++++++++++ .../HealthModelMetricTests.cs | 258 +++++++++++++++ ...erredIngestionConfiguration.verified.bicep | 62 ++++ ...ferredIngestionConfiguration.verified.json | 1 + ...TriStateAndEscapesLabelValues.verified.txt | 1 + ...dCreatesMonitoringResources.verified.bicep | 179 +++++++++++ ...ndCreatesMonitoringResources.verified.json | 1 + ...ing.CodeGeneration.TypeScript.Tests.csproj | 1 + .../HealthModelExportsTests.cs | 33 ++ ...dResourceAndEndpointSignatures.verified.ts | 6 + .../Shared/DashboardModel/ModelTestHelpers.cs | 5 +- 57 files changed, 2433 insertions(+), 319 deletions(-) create mode 100644 playground/HealthModel/HealthModel.Metrics/HealthModel.Metrics.csproj create mode 100644 playground/HealthModel/HealthModel.Metrics/HealthModelMetrics.cs create mode 100644 playground/HealthModel/HealthModel.Metrics/Program.cs create mode 100644 playground/HealthModel/HealthModelSandbox.AppHost/aspire-healthmodel.json create mode 100644 playground/HealthModel/HealthModelScenario.cs create mode 100644 src/Aspire.Hosting.Azure.HealthModels/Aspire.Hosting.Azure.HealthModels.csproj create mode 100644 src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelCollectorExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelResource.cs create mode 100644 src/Aspire.Hosting.Azure.HealthModels/HealthModelBicepGenerator.cs create mode 100644 src/Aspire.Hosting.Azure.HealthModels/HealthModelBindingValidator.cs create mode 100644 src/Aspire.Hosting.Azure.HealthModels/README.md rename src/{Aspire.Dashboard/Model/HealthModel => Shared/HealthModels}/DependenciesAggregation.cs (92%) rename src/{Aspire.Dashboard/Model/HealthModel => Shared/HealthModels}/EntityImpact.cs (96%) create mode 100644 src/Shared/HealthModels/HealthModelContract.cs create mode 100644 src/Shared/HealthModels/HealthModelDocument.cs rename src/{Aspire.Dashboard/Model/HealthModel => Shared/HealthModels}/HealthState.cs (91%) create mode 100644 src/Shared/HealthModels/SignalKind.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureHealthModelTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/HealthModelMetricTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.CollectorPublishesManagedIdentityAndDeferredIngestionConfiguration.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.CollectorPublishesManagedIdentityAndDeferredIngestionConfiguration.verified.json create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PrometheusQueryPreservesTriStateAndEscapesLabelValues.verified.txt create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.json create mode 100644 tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/HealthModelExportsTests.cs create mode 100644 tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HealthModelExportsTests.PublishingApisGenerateTypedResourceAndEndpointSignatures.verified.ts diff --git a/Aspire.slnx b/Aspire.slnx index d6cb5778e9b..729fea81b7c 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -101,6 +101,7 @@ + @@ -287,6 +288,7 @@ + diff --git a/playground/HealthModel/HealthModel.Metrics/HealthModel.Metrics.csproj b/playground/HealthModel/HealthModel.Metrics/HealthModel.Metrics.csproj new file mode 100644 index 00000000000..0fc16173a67 --- /dev/null +++ b/playground/HealthModel/HealthModel.Metrics/HealthModel.Metrics.csproj @@ -0,0 +1,13 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + diff --git a/playground/HealthModel/HealthModel.Metrics/HealthModelMetrics.cs b/playground/HealthModel/HealthModel.Metrics/HealthModelMetrics.cs new file mode 100644 index 00000000000..31660033d61 --- /dev/null +++ b/playground/HealthModel/HealthModel.Metrics/HealthModelMetrics.cs @@ -0,0 +1,63 @@ +// 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 System.Text; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace HealthModelPlayground; + +internal static class HealthModelMetrics +{ + public const string ContentType = "text/plain; version=0.0.4; charset=utf-8"; + + public static string Format(HealthReport report, IEnumerable resources) + { + var output = new StringBuilder(); + output.Append("# HELP aspire_health_status Simulated resource health: 0 = Unhealthy, 1 = Degraded, 2 = Healthy.\n"); + output.Append("# TYPE aspire_health_status gauge\n"); + + foreach (var resource in resources.OrderBy(resource => resource.Name, StringComparer.Ordinal)) + { + // An absent check is no data, even if the scenario's expected status is Healthy. + // In particular, do not use the aggregate report status for an individual resource. + if (report.Entries.TryGetValue(resource.HealthCheckName, out var entry)) + { + AppendSample(output, resource.Name, resource.HealthCheckName, entry.Status); + } + + // These entities run inside this serving process, rather than representing real databases. + AppendSample(output, resource.Name, "resource-state", HealthStatus.Healthy); + } + + return output.ToString(); + } + + private static void AppendSample(StringBuilder output, string resourceName, string healthCheck, HealthStatus status) + { + output.Append("aspire_health_status{resource_name=\""); + AppendLabelValue(output, resourceName); + output.Append("\",health_check=\""); + AppendLabelValue(output, healthCheck); + output.Append("\",replica_index=\"1\"} "); + output.Append(((int)status).ToString(CultureInfo.InvariantCulture)); + output.Append('\n'); + } + + private static void AppendLabelValue(StringBuilder output, string value) + { + // A label such as api"\namenext is written as api\"\\name\nnext. + // Prometheus text format escapes backslashes, quotes, and line feeds: + // https://prometheus.io/docs/instrumenting/exposition_formats/#text-format-details + foreach (var character in value) + { + output.Append(character switch + { + '\\' => "\\\\", + '"' => "\\\"", + '\n' => "\\n", + _ => character.ToString() + }); + } + } +} diff --git a/playground/HealthModel/HealthModel.Metrics/Program.cs b/playground/HealthModel/HealthModel.Metrics/Program.cs new file mode 100644 index 00000000000..10488c93fca --- /dev/null +++ b/playground/HealthModel/HealthModel.Metrics/Program.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using HealthModelPlayground; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +var builder = WebApplication.CreateBuilder(args); +HealthModelScenario.AddHealthChecks(builder.Services); + +var app = builder.Build(); + +app.MapGet("/metrics", async (HealthCheckService healthChecks, CancellationToken cancellationToken) => +{ + var report = await healthChecks.CheckHealthAsync(cancellationToken); + return Results.Text(HealthModelMetrics.Format(report, HealthModelScenario.Resources), HealthModelMetrics.ContentType); +}); + +// Process liveness is independent of the intentionally degraded and unhealthy simulated resources. +app.MapGet("/alive", () => Results.Text("Alive\n", "text/plain")); + +app.Run(); diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs b/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs index 0e862cfcb76..925ced2149a 100644 --- a/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs +++ b/playground/HealthModel/HealthModelSandbox.AppHost/AppHost.cs @@ -1,10 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREAZUREHEALTH001 + using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; +using HealthModelPlayground; // Playground for the health model and the resource graph. // @@ -35,41 +36,39 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.Services.TryAddEventingSubscriber(); - -// Reference the server in this simulated topology. Referencing the database as well as declaring its -// parent would give it two incoming edges and leave the server at the top level of the graph. -var ordersDbServer = AddTestResource("orders-db-server", HealthStatus.Healthy, "Accepting connections."); -AddTestResource("orders-db", HealthStatus.Healthy, "Migrations applied.") - .WithParentRelationship(ordersDbServer); - -var paymentsGateway = AddTestResource("payments-gateway", HealthStatus.Degraded, "Elevated latency from the payment provider."); - -var checkoutApi = AddTestResource("checkout-api", HealthStatus.Healthy, "Accepting orders.") - .WithReferenceRelationship(ordersDbServer) - .WithReferenceRelationship(paymentsGateway); - -// Catalog branch. -var catalogDbServer = AddTestResource("catalog-db-server", HealthStatus.Healthy, "Accepting connections."); -AddTestResource("catalog-db", HealthStatus.Healthy, "Migrations applied.") - .WithParentRelationship(catalogDbServer); - -var searchIndex = AddTestResource("search-index", HealthStatus.Unhealthy, "Index rebuild failed.", exceptionMessage: "Shard 3 is offline."); - -var catalogApi = AddTestResource("catalog-api", HealthStatus.Healthy, "Serving product data.") - .WithReferenceRelationship(catalogDbServer) - .WithReferenceRelationship(searchIndex); - -// Identity branch, kept entirely healthy so there is a green path to compare the other two against. -var identityCache = AddTestResource("identity-cache", HealthStatus.Healthy, "Cache warm."); +HealthModelScenario.AddHealthChecks(builder.Services); +var resources = HealthModelScenario.Resources.ToDictionary(resource => resource.Name, AddTestResource, StringComparer.Ordinal); +foreach (var resource in HealthModelScenario.Resources) +{ + if (resource.ParentName is { } parent) + { + resources[resource.Name].WithParentRelationship(resources[parent]); + } + foreach (var dependency in resource.Dependencies) + { + resources[resource.Name].WithReferenceRelationship(resources[dependency]); + } +} -var identityApi = AddTestResource("identity-api", HealthStatus.Healthy, "Issuing tokens.") - .WithReferenceRelationship(identityCache); +if (builder.ExecutionContext.IsPublishMode) +{ + builder.AddAzureContainerAppEnvironment("azure"); + var health = builder.AddAzureHealthModel("health", "aspire-healthmodel.json"); + var metrics = builder.AddProject("health-metrics") + .WithHttpEndpoint(name: "http", targetPort: 8080) + .WithHttpHealthCheck("/alive") + .PublishAsAzureContainerApp((_, app) => + { + app.Template.Scale.MinReplicas = 1; + app.Template.Scale.MaxReplicas = 1; + }); -AddTestResource("storefront", HealthStatus.Healthy, "Serving customers.") - .WithReferenceRelationship(checkoutApi) - .WithReferenceRelationship(catalogApi) - .WithReferenceRelationship(identityApi); + builder.AddAzureContainerAppsHealthModelCollector("health-collector", health, metrics.GetEndpoint("http")); +} +else +{ + builder.Services.TryAddEventingSubscriber(); +} #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging @@ -78,21 +77,19 @@ // dashboard launch experience, Refer to Directory.Build.props for the path to // the dashboard binary (defaults to the Aspire.Dashboard bin output in the // artifacts dir). -builder.AddProject(KnownResourceNames.AspireDashboard); +if (builder.ExecutionContext.IsRunMode) +{ + builder.AddProject(KnownResourceNames.AspireDashboard); +} #endif builder.Build().Run(); -IResourceBuilder AddTestResource(string name, HealthStatus status, string? description = null, string? exceptionMessage = null) +IResourceBuilder AddTestResource(HealthModelScenarioResource resource) { - builder.Services.AddHealthChecks() - .AddCheck( - $"{name}_check", - () => new HealthCheckResult(status, description, exceptionMessage is null ? null : new InvalidOperationException(exceptionMessage))); - return builder - .AddResource(new TestResource(name)) - .WithHealthCheck($"{name}_check") + .AddResource(new TestResource(resource.Name)) + .WithHealthCheck(resource.HealthCheckName) .WithInitialState(new() { ResourceType = "Test Resource", diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj b/playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj index 6854f056c4e..88316203fcf 100644 --- a/playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj +++ b/playground/HealthModel/HealthModelSandbox.AppHost/HealthModelSandbox.AppHost.csproj @@ -11,10 +11,14 @@ + + + + diff --git a/playground/HealthModel/HealthModelSandbox.AppHost/aspire-healthmodel.json b/playground/HealthModel/HealthModelSandbox.AppHost/aspire-healthmodel.json new file mode 100644 index 00000000000..0efd08615e5 --- /dev/null +++ b/playground/HealthModel/HealthModelSandbox.AppHost/aspire-healthmodel.json @@ -0,0 +1,204 @@ +{ + "schemaVersion": 1, + "name": "aspire-app-health", + "applicationName": "HealthModelSandbox.AppHost", + "entities": [ + { + "name": "aspire-app-health", + "displayName": "AppHost", + "canvasPosition": { "x": 68, "y": 0 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [] + }, + { + "name": "resource-catalog-api-2047be761f908cf3", + "displayName": "catalog-api", + "aspireResourceName": "catalog-api", + "replicaIndex": 1, + "canvasPosition": { "x": -408, "y": 360 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "catalog-api_check", "kind": "External" } + ] + }, + { + "name": "resource-catalog-db-6ec20491e53f0925", + "displayName": "catalog-db", + "aspireResourceName": "catalog-db", + "replicaIndex": 1, + "canvasPosition": { "x": -544, "y": 720 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "catalog-db_check", "kind": "External" } + ] + }, + { + "name": "resource-catalog-db-server-169a33d1a81f5e45", + "displayName": "catalog-db-server", + "aspireResourceName": "catalog-db-server", + "replicaIndex": 1, + "canvasPosition": { "x": -544, "y": 540 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "catalog-db-server_check", "kind": "External" } + ] + }, + { + "name": "resource-checkout-api-bf814b2008e64f0a", + "displayName": "checkout-api", + "aspireResourceName": "checkout-api", + "replicaIndex": 1, + "canvasPosition": { "x": 136, "y": 360 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "checkout-api_check", "kind": "External" } + ] + }, + { + "name": "resource-identity-api-4d9137704baac75b", + "displayName": "identity-api", + "aspireResourceName": "identity-api", + "replicaIndex": 1, + "canvasPosition": { "x": 544, "y": 360 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "identity-api_check", "kind": "External" } + ] + }, + { + "name": "resource-identity-cache-b2e82ca0e70c363e", + "displayName": "identity-cache", + "aspireResourceName": "identity-cache", + "replicaIndex": 1, + "canvasPosition": { "x": 544, "y": 540 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "identity-cache_check", "kind": "External" } + ] + }, + { + "name": "resource-orders-db-0ea5c392b3872c96", + "displayName": "orders-db", + "aspireResourceName": "orders-db", + "replicaIndex": 1, + "canvasPosition": { "x": 0, "y": 720 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "orders-db_check", "kind": "External" } + ] + }, + { + "name": "resource-orders-db-server-4c88452aa34dadd8", + "displayName": "orders-db-server", + "aspireResourceName": "orders-db-server", + "replicaIndex": 1, + "canvasPosition": { "x": 0, "y": 540 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "orders-db-server_check", "kind": "External" } + ] + }, + { + "name": "resource-payments-gateway-333165ebb58b302b", + "displayName": "payments-gateway", + "aspireResourceName": "payments-gateway", + "replicaIndex": 1, + "canvasPosition": { "x": 272, "y": 540 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "payments-gateway_check", "kind": "External" } + ] + }, + { + "name": "resource-search-index-ebbf9cdc34d61672", + "displayName": "search-index", + "aspireResourceName": "search-index", + "replicaIndex": 1, + "canvasPosition": { "x": -272, "y": 540 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "search-index_check", "kind": "External" } + ] + }, + { + "name": "resource-storefront-10139a6ad125cf88", + "displayName": "storefront", + "aspireResourceName": "storefront", + "replicaIndex": 1, + "canvasPosition": { "x": 68, "y": 180 }, + "impact": "Standard", + "dependencies": { "aggregationType": "WorstOf", "ignoreUnknown": true }, + "localSignals": [ + { "name": "resource-state", "kind": "External" }, + { "name": "storefront_check", "kind": "External" } + ] + } + ], + "relationships": [ + { + "parentEntityName": "aspire-app-health", + "childEntityName": "resource-storefront-10139a6ad125cf88" + }, + { + "parentEntityName": "resource-storefront-10139a6ad125cf88", + "childEntityName": "resource-catalog-api-2047be761f908cf3" + }, + { + "parentEntityName": "resource-storefront-10139a6ad125cf88", + "childEntityName": "resource-checkout-api-bf814b2008e64f0a" + }, + { + "parentEntityName": "resource-storefront-10139a6ad125cf88", + "childEntityName": "resource-identity-api-4d9137704baac75b" + }, + { + "parentEntityName": "resource-catalog-api-2047be761f908cf3", + "childEntityName": "resource-catalog-db-server-169a33d1a81f5e45" + }, + { + "parentEntityName": "resource-catalog-api-2047be761f908cf3", + "childEntityName": "resource-search-index-ebbf9cdc34d61672" + }, + { + "parentEntityName": "resource-catalog-db-server-169a33d1a81f5e45", + "childEntityName": "resource-catalog-db-6ec20491e53f0925" + }, + { + "parentEntityName": "resource-checkout-api-bf814b2008e64f0a", + "childEntityName": "resource-orders-db-server-4c88452aa34dadd8" + }, + { + "parentEntityName": "resource-checkout-api-bf814b2008e64f0a", + "childEntityName": "resource-payments-gateway-333165ebb58b302b" + }, + { + "parentEntityName": "resource-orders-db-server-4c88452aa34dadd8", + "childEntityName": "resource-orders-db-0ea5c392b3872c96" + }, + { + "parentEntityName": "resource-identity-api-4d9137704baac75b", + "childEntityName": "resource-identity-cache-b2e82ca0e70c363e" + } + ] +} diff --git a/playground/HealthModel/HealthModelScenario.cs b/playground/HealthModel/HealthModelScenario.cs new file mode 100644 index 00000000000..3a34e000296 --- /dev/null +++ b/playground/HealthModel/HealthModelScenario.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace HealthModelPlayground; + +// This is a simulation, not a translation of arbitrary AppHost health-check delegates. +// Both the local resources and the deployed producer register these same validators. +internal static class HealthModelScenario +{ + public static IReadOnlyList Resources { get; } = + [ + new("storefront", "Serving customers.", HealthStatus.Healthy, null, ["checkout-api", "catalog-api", "identity-api"], null), + new("checkout-api", "Accepting orders.", HealthStatus.Healthy, null, ["orders-db-server", "payments-gateway"], null), + new("orders-db-server", "Accepting connections.", HealthStatus.Healthy, null, [], null), + new("orders-db", "Migrations applied.", HealthStatus.Healthy, "orders-db-server", [], null), + new("payments-gateway", "Elevated latency from the payment provider.", HealthStatus.Degraded, null, [], null), + new("catalog-api", "Serving product data.", HealthStatus.Healthy, null, ["catalog-db-server", "search-index"], null), + new("catalog-db-server", "Accepting connections.", HealthStatus.Healthy, null, [], null), + new("catalog-db", "Migrations applied.", HealthStatus.Healthy, "catalog-db-server", [], null), + new("search-index", "Index rebuild failed.", HealthStatus.Unhealthy, null, [], "Shard 3 is offline."), + new("identity-api", "Issuing tokens.", HealthStatus.Healthy, null, ["identity-cache"], null), + new("identity-cache", "Cache warm.", HealthStatus.Healthy, null, [], null) + ]; + + public static HealthCheckResult Evaluate(string resourceName) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + var resource = Resources.FirstOrDefault(resource => string.Equals(resource.Name, resourceName, StringComparison.Ordinal)); + if (resource is null) + { + throw new ArgumentException($"Unknown health-model scenario resource '{resourceName}'.", nameof(resourceName)); + } + + return new HealthCheckResult( + resource.HealthStatus, + resource.Description, + resource.ExceptionMessage is null ? null : new InvalidOperationException(resource.ExceptionMessage)); + } + + public static IHealthChecksBuilder AddHealthChecks(IServiceCollection services) + { + var builder = services.AddHealthChecks(); + foreach (var resource in Resources) + { + builder.AddCheck(resource.HealthCheckName, () => Evaluate(resource.Name)); + } + + return builder; + } +} + +internal sealed record HealthModelScenarioResource( + string Name, + string Description, + HealthStatus HealthStatus, + string? ParentName, + IReadOnlyList Dependencies, + string? ExceptionMessage) +{ + public string HealthCheckName => $"{Name}_check"; +} diff --git a/playground/HealthModel/README.md b/playground/HealthModel/README.md index c2dc7f21eaa..d17b0aa1e4e 100644 --- a/playground/HealthModel/README.md +++ b/playground/HealthModel/README.md @@ -43,7 +43,7 @@ All other resources report Healthy. Once startup completes, the graph should sho | identity-api | Healthy | Healthy dependencies | | storefront / AppHost | Unhealthy | catalog-api | -To try a different scenario, change a leaf's `HealthStatus` in `AppHost.cs` and restart. +To try a different scenario, change a leaf's `HealthStatus` in `HealthModelScenario.cs` and restart. Both **Health** and **Resources > Graph** derive relationships from the AppHost. Health no longer invents Services/Infrastructure groups or automatically limits the impact of containers. @@ -94,51 +94,101 @@ No environment variables, credentials, endpoint addresses, observed health value exception text are exported. Root identity, topology and positions are deliberately separate from the live signal readings. -### Publishing boundary +### Publish the model -The export is a **portable definition, not an ARM/Bicep deployment template**. This iteration -does not register an Azure publisher and does not create cloud resources. A future publisher -should consume the project-owned definition rather than reconstructing a different graph: -emit entities using its stable identities, emit the same relationships, and copy -`canvasPosition`, impact and dependency settings to the Azure model. +The AppHost opts into the experimental `Aspire.Hosting.Azure.HealthModels` integration in publish +mode. Local startup remains container-free and never provisions Azure. -Local lifecycle and health-check signals need explicit cloud equivalents (metrics, queries, -or an external signal producer). The publisher must also bind AppHost resources to deployed -ARM resource IDs and configure authentication. A matching picture alone does not establish -equivalent cloud health evaluation. +The checked-in `HealthModelSandbox.AppHost\aspire-healthmodel.json` contains the default 12-entity, +11-relationship design with the same stable IDs and positions as the local dashboard. To publish +your edits, **Save changes**, **Export model**, and replace that project-owned file. Browser storage +is not read by the publisher. After changing relationships or check registrations, re-export the +definition; stale bindings fail publishing rather than silently dropping checks. -The initial documented Bicep target is -[`Microsoft.CloudHealth/healthmodels@2026-05-01-preview`](https://learn.microsoft.com/azure/templates/microsoft.cloudhealth/2026-05-01-preview/healthmodels). -The health model is **a separate Azure resource**, not a workspace or a child of a workspace: +From the repository root, generate the assets without Azure credentials or cloud mutations: -| Resource | Purpose | +```powershell +aspire publish --apphost .\playground\HealthModel\HealthModelSandbox.AppHost\HealthModelSandbox.AppHost.csproj --output-path .\artifacts\health-model-publish --non-interactive +``` + +For testing the CLI built from this checkout, replace `aspire` with +`dotnet .\artifacts\bin\Aspire.Cli\Debug\net10.0\aspire.dll`. + +| Output | Purpose | |---|---| -| `Microsoft.CloudHealth/healthmodels` | Owns entities, relationships and health configuration | -| `Microsoft.Monitor/accounts` | Optional Azure Monitor workspace for Prometheus/PromQL signals | -| `Microsoft.OperationalInsights/workspaces` | Optional Log Analytics workspace for KQL signals | - -A future publisher should create the appropriate signal sources rather than automatically -creating both workspace types. Azure resource metrics can reference the monitored resource directly. - -Important integration constraints: - -- Azure creates its root entity with the **model's name**. The publisher must keep that identity - consistent with the root referenced by the exported relationships. -- Relationship endpoints use entity resource names. Rewiring requires replacing the relationship, - not updating its endpoints in place. -- The local editor preserves X/Y values independently of zoom. The REST schema defines floating-point - coordinates while the generated Bicep reference presents integers, and the portal's coordinate - origin/anchor is not documented. Validate the conversion against Azure before claiming identical - positioning; do not silently round exported coordinates. -- `signalGroups.external` is read-only. Aspire health-check results require ongoing - [health-report ingestion](https://learn.microsoft.com/azure/azure-monitor/health-models/health-report-ingestion) - or an explicit metric/query equivalent. Bicep cannot provision a persistent external health result. -- The local threshold evaluator follows the inclusive comparisons in the pinned API schema. - Conceptual examples differ at equality, and some Unknown-state edge cases are underspecified. - Cloud execution parity still needs service-level validation. - -This preview does not include historical timelines, alert delivery, Azure discovery, -cloud metric/query execution or arbitrary browser-defined entities and relationships. +| `main.bicep` | Infrastructure entrypoint using Aspire's existing Azure publisher | +| `health\health.bicep` | AHM, entities, relationships, 22 signal definitions, AMW, DCE, DCR and RBAC | +| `health-metrics\health-metrics.bicep` | Internally accessible metrics producer, kept at one replica | +| `health-collector\health-collector.bicep` | Prometheus collector with managed-identity remote write | +| `azure\`, `azure-acr\` | Container Apps environment and registry managed by Aspire | + +The compute modules are separate from `main.bicep`, as in the existing Azure publishing pipeline. +Their image and infrastructure parameters remain deferred. `aspire deploy` is the operation that +builds/pushes the producer image, provisions infrastructure, and applies the compute modules; running +only `main.bicep` will not deploy the producer or collector. + +The AHM module targets **`Microsoft.CloudHealth/healthmodels@2026-09-01-preview`**. The health +model is a separate Azure resource, not a workspace child. It preserves entity identities, directed +edges, fractional coordinates, impact, objectives and dependency aggregation. The identities get +no broad owner/contributor permissions: the model has **Monitoring Reader** on the AMW and the +collector has **Monitoring Metrics Publisher** on the DCR. + +### Health signals in Azure + +`HealthModelScenario.cs` owns both the simulated topology and its validators. The AppHost and +`HealthModel.Metrics` register the same checks with `HealthCheckService`. `/metrics` executes +those checks and reports each resource's own status, not its already-aggregated parent status: + +```text +aspire_health_status{resource_name="payments-gateway",health_check="payments-gateway_check",replica_index="1"} 1 +aspire_health_status{resource_name="search-index",health_check="search-index_check",replica_index="1"} 0 +``` + +The producer emits 22 series: 11 check results plus 11 lifecycle results. Values are **2 Healthy, +1 Degraded, 0 Unhealthy**. Simulated lifecycle states are Healthy while the producer is serving; +they do not claim to monitor real databases. `/alive` reports process liveness independently of +the deliberately unhealthy sample entities. + +Prometheus scrapes every 30 seconds and remote-writes through the DCE/DCR into the AMW using a +dedicated user-assigned identity. The model evaluates one `PrometheusMetricsQuery` signal per +binding, once per minute: `< 1` is Unhealthy and `< 2` is Degraded. AHM performs parent rollup using +the saved dependency policies. No credentials or health-report exception details appear in the metrics. + +To inspect the producer alone: + +```powershell +dotnet run --project .\playground\HealthModel\HealthModel.Metrics\HealthModel.Metrics.csproj --no-launch-profile -- --urls http://localhost:5088 +``` + +### Deployment boundary + +Deployment requires an Azure subscription/region supporting the current CloudHealth API and managed +Prometheus, Container Apps, a working container-image build path, and permission to create resources +and role assignments. The generated model uses authenticated public-network ingestion; private-link +configuration and existing-workspace reuse are outside this first version. + +This remains a simulation. Arbitrary AppHost delegates are not automatically copied into deployed +workloads. For a real application, instrument the workload or supply an equivalent probe that emits +the documented resource/check/replica labels. The sample's logical database entities deliberately do +not pretend to have deployed database ARM IDs. + +Missing, invalid or older-than-three-minute measurements produce an empty PromQL result instead of +manufactured Healthy values. AHM's empty-result/Unknown transitions, RBAC propagation and Azure portal +coordinate anchoring still require service-level validation. Saved numeric coordinates are preserved, +but identical pixels and full local/cloud evaluation parity are not yet claimed. + +Relationship endpoints use entity names; rewiring creates a replacement relationship. The first +version uses incremental deployment, so removing entities, relationships or definitions from the +file does not automatically delete their old Azure resources. Use a fresh model for topology-removal +experiments or explicitly remove obsolete resources before comparing graphs. Deletion reconciliation +is not implemented. + +The generated Azure resource group owns the sample infrastructure. Use the application's normal +Azure teardown/resource-group cleanup workflow when finished; publishing itself creates no Azure +resources and needs no cloud cleanup. + +The local dashboard does not include historical timelines, alert delivery, Azure discovery, +remote Azure health reads or arbitrary browser-defined entities and relationships. Cyclic AppHost references remain inspectable in **Resources > Graph**, but the Health designer reports them as unsupported rather than silently dropping edges. Requiring an acyclic, root-connected topology is a local lite-product restriction, not a claim that Azure prohibits diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index f66bc9c90fd..95ef0ab21d6 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -84,6 +84,7 @@ + @@ -290,6 +291,12 @@ + + + + + + diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs index c70557a1175..209d1940206 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs @@ -1,63 +1,11 @@ // 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.Immutable; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; - namespace Aspire.Dashboard.Model.HealthModel; -/// Coordinates in the model's canvas space, independent of zoom and viewport size. -public sealed record HealthModelCanvasPosition(double X, double Y); - -/// A local signal binding, without measurements, descriptions, or exception data. -public sealed record HealthModelSignalBinding(string Name, SignalKind Kind); - -/// Portable configuration for an entity; observed health is deliberately excluded. -public sealed record HealthModelEntityConfiguration +internal static class HealthModelDocuments { - public required string Name { get; init; } - public required string DisplayName { get; init; } - public string? AspireResourceName { get; init; } - public int? ReplicaIndex { get; init; } - public required HealthModelCanvasPosition CanvasPosition { get; init; } - public EntityImpact Impact { get; init; } - public double? HealthObjective { get; init; } - public DependenciesAggregation Dependencies { get; init; } = DependenciesAggregation.WorstOf; - public ImmutableArray LocalSignals { get; init; } = []; -} - -/// A versioned, portable model definition for saved layout and future publishing integration. -/// -/// This is not an ARM template. Entity names, relationships, impact, dependency settings and canvas -/// coordinates map to Microsoft.CloudHealth entities. Local signal bindings still require an Azure -/// metric/query mapping or an external signal producer when a publisher consumes this definition. -/// -public sealed record HealthModelDocument -{ - [JsonRequired] - public int SchemaVersion { get; init; } = 1; - public required string Name { get; init; } - public required string ApplicationName { get; init; } - public required ImmutableArray Entities { get; init; } - public required ImmutableArray Relationships { get; init; } -} - -internal static partial class HealthModelDocuments -{ - public const int MaxFileSize = 2 * 1024 * 1024; - private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - PropertyNameCaseInsensitive = false, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, - MaxDepth = 32, - Converters = { new JsonStringEnumConverter(allowIntegerValues: false) } - }; - - [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]$", RegexOptions.CultureInvariant)] - private static partial Regex EntityNamePattern(); + public const int MaxFileSize = HealthModelContract.MaxFileSize; public static HealthModelDocument Create(HealthModelDefinition definition, string applicationName) { @@ -113,16 +61,12 @@ public static HealthModelDefinition Apply(HealthModelDefinition live, HealthMode }; } - public static string Serialize(HealthModelDocument document) => JsonSerializer.Serialize(document, s_jsonOptions); + public static string Serialize(HealthModelDocument document) => HealthModelContract.Serialize(document); public static HealthModelDocument Deserialize(string json, HealthModelDocument current) { - // A model file has the shape { schemaVersion: 1, name, applicationName, entities: [...], - // relationships: [{ parentEntityName, childEntityName }] }. Unknown fields are rejected so - // runtime measurements or unsupported Azure settings cannot be silently discarded on import. - var document = JsonSerializer.Deserialize(json, s_jsonOptions) - ?? throw new InvalidDataException("The model document is null."); - Validate(document, current.ApplicationName); + var document = HealthModelContract.Deserialize(json); + ValidateApplication(document, current.ApplicationName); if (document.Name != current.Name || !document.Entities.Select(e => (e.Name, e.AspireResourceName, e.ReplicaIndex)).ToHashSet() .SetEquals(current.Entities.Select(e => (e.Name, e.AspireResourceName, e.ReplicaIndex))) || @@ -140,80 +84,19 @@ public static HealthModelDocument Deserialize(string json, HealthModelDocument c public static void Validate(HealthModelDocument document, string applicationName) { - if (document.SchemaVersion != 1 || document.ApplicationName != applicationName || - document.Entities.IsDefaultOrEmpty || document.Entities.Length > 2000 || document.Relationships.IsDefault || - document.Relationships.Length > 10000 || string.IsNullOrEmpty(document.Name)) - { - throw new InvalidDataException("The model version, application or collection sizes are invalid."); - } - - var names = new HashSet(StringComparer.Ordinal); - foreach (var entity in document.Entities) - { - if (entity is null || string.IsNullOrEmpty(entity.Name) || !EntityNamePattern().IsMatch(entity.Name) || - !names.Add(entity.Name) || string.IsNullOrWhiteSpace(entity.DisplayName) || entity.DisplayName.Length > 260 || - entity.CanvasPosition is null || !IsValidPosition(entity.CanvasPosition) || - !Enum.IsDefined(entity.Impact) || entity.Dependencies is null || entity.LocalSignals.IsDefault || - entity.LocalSignals.Length > 1000 || entity.LocalSignals.Any(s => s is null || string.IsNullOrEmpty(s.Name) || !Enum.IsDefined(s.Kind)) || - entity.Name != document.Name && (string.IsNullOrEmpty(entity.AspireResourceName) || entity.ReplicaIndex is null) || - entity.ReplicaIndex is < 0 || entity.HealthObjective is { } objective && (!double.IsFinite(objective) || objective < 0 || objective > 100)) - { - throw new InvalidDataException("An entity has an invalid name, binding, position, impact or health objective."); - } - ValidateAggregation(entity.Dependencies); - } - if (!names.Contains(document.Name) || document.Entities.Single(e => e.Name == document.Name).Impact != EntityImpact.Standard || - document.Relationships.Any(r => r is null || r.ChildEntityName == document.Name || - !names.Contains(r.ParentEntityName) || !names.Contains(r.ChildEntityName))) - { - throw new InvalidDataException("The model must have a standard-impact root with valid relationships and no parent."); - } - - var definition = new HealthModelDefinition - { - Name = document.Name, - Entities = [.. document.Entities.Select(e => new HealthModelEntity { Name = e.Name })], - Relationships = document.Relationships - }; - var topology = HealthModelTopology.Create(definition); - var reachable = new HashSet(StringComparer.Ordinal) { document.Name }; - foreach (var entity in topology.Order.Where(e => reachable.Contains(e.Name))) - { - reachable.UnionWith(topology.Children[entity.Name]); - } - if (reachable.Count != names.Count) - { - throw new InvalidDataException("All entities must be reachable from the model root."); - } + HealthModelContract.Validate(document); + ValidateApplication(document, applicationName); } - public static bool IsValidPosition(HealthModelCanvasPosition position) => - double.IsFinite(position.X) && double.IsFinite(position.Y) && - Math.Abs(position.X) <= 1_000_000 && Math.Abs(position.Y) <= 1_000_000; + public static bool IsValidPosition(HealthModelCanvasPosition position) => HealthModelContract.IsValidPosition(position); - public static void ValidateAggregation(DependenciesAggregation aggregation) - { - if (!Enum.IsDefined(aggregation.AggregationType) || !Enum.IsDefined(aggregation.Unit)) - { - throw new InvalidDataException("The dependency aggregation type or unit is invalid."); - } - if (aggregation.AggregationType == DependenciesAggregationType.WorstOf) - { - if (aggregation.DegradedThreshold is not null || aggregation.UnhealthyThreshold is not null) - { - throw new InvalidDataException("Worst-of rollup does not accept thresholds."); - } - return; - } + public static void ValidateAggregation(DependenciesAggregation aggregation) => HealthModelContract.ValidateAggregation(aggregation); - if (aggregation.UnhealthyThreshold is not { } unhealthy || !ValidThreshold(unhealthy) || - aggregation.DegradedThreshold is { } degraded && (!ValidThreshold(degraded) || - (aggregation.AggregationType == DependenciesAggregationType.MinHealthy ? degraded <= unhealthy : degraded >= unhealthy))) + private static void ValidateApplication(HealthModelDocument document, string applicationName) + { + if (document.ApplicationName != applicationName) { - throw new InvalidDataException("Set an unhealthy threshold and order the thresholds from degraded to unhealthy."); + throw new InvalidDataException("The model application does not match this AppHost."); } - - bool ValidThreshold(double value) => double.IsFinite(value) && value >= 0 && - (aggregation.Unit == AggregationUnit.Percentage ? value <= 100 : value == Math.Truncate(value)); } } diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs index d5199b42ec9..ab69416d636 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs @@ -64,19 +64,6 @@ public sealed record HealthModelEntity public string? Category { get; init; } } -/// -/// A directed parent-to-child edge in a health model. -/// -/// -/// Azure models relationships as standalone resources with immutable parentEntityName and -/// childEntityName, and carries no health or aggregation configuration on the edge itself. Rollup -/// tuning lives on the two entities instead: on the child and -/// on the parent. -/// -/// The name of the parent entity. -/// The name of the child entity. -public sealed record HealthModelRelationship(string ParentEntityName, string ChildEntityName); - /// /// A complete health model: a set of entities and the relationships that connect them. /// diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs index 2e6aedb5615..5421e9efc80 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs @@ -3,29 +3,6 @@ namespace Aspire.Dashboard.Model.HealthModel; -/// -/// The data source a signal reads from. -/// -/// -/// Mirrors the SignalKind discriminator in Azure Monitor health models. Signals produced locally by the -/// dashboard use because, like Azure external signals, their state is reported by the -/// app host rather than computed by the health model service from a metric or query. -/// -public enum SignalKind -{ - /// A platform metric read from an Azure resource. - AzureResourceMetric, - - /// A KQL query run against a Log Analytics workspace. - LogAnalyticsQuery, - - /// A PromQL query run against an Azure Monitor workspace. - PrometheusMetricsQuery, - - /// A state reported by an external producer rather than evaluated by the health model itself. - External -} - /// /// The comparison used to test an observed signal value against a threshold. /// diff --git a/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs b/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs index 26d3cc7f5ee..1f02d8be3a1 100644 --- a/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs +++ b/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs @@ -77,9 +77,9 @@ public class HealthModel { public static string HealthModelSubscriptionError => ResourceManager.GetString("HealthModelSubscriptionError", resourceCulture); /// Historical models are read-only. public static string HealthModelReadOnly => ResourceManager.GetString("HealthModelReadOnly", resourceCulture); - /// Features outside this local preview. + /// Local health and explicitly configured Azure publishing are separate. public static string HealthModelCloudBoundary => ResourceManager.GetString("HealthModelCloudBoundary", resourceCulture); - /// Purpose of the exported model definition. + /// Save the definition in the AppHost and configure a matching publisher and metrics producer. public static string HealthModelDefinitionHint => ResourceManager.GetString("HealthModelDefinitionHint", resourceCulture); /// Entity and relationship counts. public static string HealthModelEntityCount => ResourceManager.GetString("HealthModelEntityCount", resourceCulture); diff --git a/src/Aspire.Dashboard/Resources/HealthModel.resx b/src/Aspire.Dashboard/Resources/HealthModel.resx index cab3d6d64f4..c437ed0032b 100644 --- a/src/Aspire.Dashboard/Resources/HealthModel.resx +++ b/src/Aspire.Dashboard/Resources/HealthModel.resx @@ -226,8 +226,8 @@ This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. This is a historical run. Switch to the live run to edit the model. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. {0} entities, {1} relationships{0} is an entity count, {1} is a relationship count. Healthy Degraded diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf index df231f93842..7eb8c13a4b2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf index 60eba5937b1..ac7a99a4761 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf index 93918ef2cc7..091bd562f54 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf index 4ac48a2c2a9..36169f87482 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf index 062c205a8d8..c212cbd3842 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf index 726386ad6d2..e808a4aa19f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf index a49a39900da..c279df8b644 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf index 16d8fd1e7ee..f20851ba438 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf index b0892c6f0fe..f96129a24f8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf index 649d8194440..f2fde5818e9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf index 66650d99cbc..3a4c20aa705 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf index 56e985fe6a1..fd68dd91230 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf index ed8f9b702f6..6aa9de5ca0f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf @@ -28,8 +28,8 @@ - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. - Local health checks only. Timeline history, Azure signal queries, alerts, discovery and automatic Azure provisioning are not included in this preview. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. + Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. @@ -38,8 +38,8 @@ - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. It is not a deployment template; an Azure publisher must bind resources and signals before provisioning. Azure layout and evaluation parity have not yet been validated. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. + The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. diff --git a/src/Aspire.Hosting.Azure.HealthModels/Aspire.Hosting.Azure.HealthModels.csproj b/src/Aspire.Hosting.Azure.HealthModels/Aspire.Hosting.Azure.HealthModels.csproj new file mode 100644 index 00000000000..584e677690b --- /dev/null +++ b/src/Aspire.Hosting.Azure.HealthModels/Aspire.Hosting.Azure.HealthModels.csproj @@ -0,0 +1,19 @@ + + + $(DefaultTargetFramework) + true + true + true + Azure Monitor health model publishing and managed Prometheus ingestion resources for Aspire. + aspire integration hosting azure monitoring health prometheus + $(SharedDir)Azure_256x.png + + + + + + + + + + diff --git a/src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelCollectorExtensions.cs b/src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelCollectorExtensions.cs new file mode 100644 index 00000000000..d64ead51483 --- /dev/null +++ b/src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelCollectorExtensions.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Azure.Provisioning.Expressions; +using Azure.Provisioning.Resources; + +namespace Aspire.Hosting; + +/// Deploys an authenticated Prometheus collector for health-model metrics. +public static class AzureHealthModelCollectorExtensions +{ + /// + /// Adds a publish-only collector that scrapes a metrics endpoint and remote-writes to the health + /// model's Azure Monitor workspace using its dedicated managed identity. + /// + /// The application builder. + /// The collector's unique resource name. + /// The health model supplying the ingestion rule and identity. + /// The internal endpoint exposing Prometheus metrics at /metrics. + /// The collector container builder. + /// + /// Requires an Azure Container Apps environment in publish mode. The collector has no public + /// ingress and is kept at one replica so scraping does not stop when application traffic stops. + /// + [AspireExport] + [Experimental("ASPIREAZUREHEALTH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder AddAzureContainerAppsHealthModelCollector( + this IDistributedApplicationBuilder builder, [ResourceName] string name, + IResourceBuilder healthModel, EndpointReference metricsEndpoint) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(healthModel); + ArgumentNullException.ThrowIfNull(metricsEndpoint); + + if (builder.ExecutionContext.IsRunMode) + { + return builder.CreateResourceBuilder(new ContainerResource(name)); + } + + return builder.AddContainer(name, "prom/prometheus", "v3.5.0") + .WithEnvironment("METRICS_ENDPOINT", metricsEndpoint.Property(EndpointProperty.Url)) + .WithEnvironment("REMOTE_WRITE_ENDPOINT", healthModel.Resource.RemoteWriteEndpoint) + .WithEnvironment("AZURE_CLIENT_ID", healthModel.Resource.CollectorClientId) + .WithEntrypoint("/bin/sh") + .WithArgs("-ec", CollectorCommand.ReplaceLineEndings("\n")) + .PublishAsAzureContainerApp((infrastructure, app) => + { + var identityId = healthModel.Resource.CollectorIdentityId.AsProvisioningParameter(infrastructure); + var identityKey = BicepFunction.Interpolate($"{identityId}").Compile().ToString(); + app.Identity.ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned; + app.Identity.UserAssignedIdentities[identityKey] = new UserAssignedIdentityDetails(); + app.Template.Scale.MinReplicas = 1; + app.Template.Scale.MaxReplicas = 1; + }); + } + + // The image includes BusyBox but not envsubst. Expand only known endpoint/client-id inputs into + // the config at startup. They remain deferred Bicep outputs in publish artifacts, never local URLs. + // Normalize source line endings when passing the command to the Linux shell. + // Accepted METRICS_ENDPOINT shape: https://health-metrics. or http://host:port. + internal const string CollectorCommand = """ + : "${METRICS_ENDPOINT:?A metrics endpoint is required}" + : "${REMOTE_WRITE_ENDPOINT:?An Azure remote-write endpoint is required}" + : "${AZURE_CLIENT_ID:?The collector managed identity client ID is required}" + case "$METRICS_ENDPOINT" in + https://*) scheme=https; target="${METRICS_ENDPOINT#https://}" ;; + http://*) scheme=http; target="${METRICS_ENDPOINT#http://}" ;; + *) echo "METRICS_ENDPOINT must be an HTTP or HTTPS endpoint" >&2; exit 1 ;; + esac + target="${target%/}" + printf '%s' "$target" | grep -Eq '^[a-zA-Z0-9._:-]+$' || { echo "Invalid metrics target" >&2; exit 1; } + printf '%s' "$AZURE_CLIENT_ID" | grep -Eq '^[a-fA-F0-9-]+$' || { echo "Invalid managed identity client ID" >&2; exit 1; } + printf '%s' "$REMOTE_WRITE_ENDPOINT" | grep -Eq '^https://[a-zA-Z0-9.-]+/[a-zA-Z0-9/_?=.&%-]+$' || { echo "Invalid remote-write endpoint" >&2; exit 1; } + cat > /tmp/health-prometheus.yml <Configures Azure Monitor health model publishing. +public static class AzureHealthModelExtensions +{ + /// + /// Publishes a saved health-model definition together with an Azure Monitor workspace and its + /// Prometheus ingestion endpoint, collection rule and managed identities. + /// + /// The application builder. + /// The unique Aspire resource and deployment name. + /// The exported model file, relative to the AppHost directory or absolute. + /// The health-model publishing resource. + /// + /// This resource is not added to the run-mode application and never provisions Azure during local + /// startup. Publish validates and reads the project-owned definition. Health signals query the + /// aspire_health_status Prometheus gauge; applications or probes must publish real measurements + /// using the documented labels and .NET HealthStatus values. + /// + [AspireExport] + [Experimental("ASPIREAZUREHEALTH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder AddAzureHealthModel( + this IDistributedApplicationBuilder builder, [ResourceName] string name, string definitionFile) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(definitionFile); + + var path = Path.GetFullPath(definitionFile, builder.AppHostDirectory); + var resource = new AzureHealthModelResource(name, path) + { + ValidateBindings = document => HealthModelBindingValidator.Validate(document, builder.Resources, builder.Environment.ApplicationName) + }; + if (builder.ExecutionContext.IsRunMode) + { + return builder.CreateResourceBuilder(resource); + } + + builder.AddAzureProvisioning(); + return builder.AddResource(resource).WithIconName("Heart"); + } +} diff --git a/src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelResource.cs b/src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelResource.cs new file mode 100644 index 00000000000..6584a8f013c --- /dev/null +++ b/src/Aspire.Hosting.Azure.HealthModels/AzureHealthModelResource.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using Aspire.HealthModels; + +namespace Aspire.Hosting.Azure; + +/// A publish-only Azure health model and managed Prometheus ingestion bundle. +[AspireExport] +[Experimental("ASPIREAZUREHEALTH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class AzureHealthModelResource : AzureBicepResource +{ + /// Initializes a health-model resource without reading files or contacting Azure. + /// The Aspire resource name. + /// The absolute path of the project-owned model definition. + public AzureHealthModelResource(string name, string definitionFile) : base(name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(definitionFile); + DefinitionFile = definitionFile; + } + + /// The model definition consumed during publish and deployment. + public string DefinitionFile { get; } + + internal Action? ValidateBindings { get; init; } + + /// The provisioned Azure health model resource ID. + public BicepOutputReference HealthModelId => new("healthModelId", this); + /// The Azure Monitor workspace resource ID. + public BicepOutputReference WorkspaceId => new("workspaceId", this); + /// The data collection endpoint resource ID. + public BicepOutputReference DataCollectionEndpointId => new("dataCollectionEndpointId", this); + /// The data collection rule resource ID. + public BicepOutputReference DataCollectionRuleId => new("dataCollectionRuleId", this); + /// The authenticated Prometheus remote-write URL, including the rule's immutable ID. + public BicepOutputReference RemoteWriteEndpoint => new("remoteWriteEndpoint", this); + /// The managed identity to attach to the remote-write collector. + public BicepOutputReference CollectorIdentityId => new("collectorIdentityId", this); + /// The collector's user-assigned managed identity client ID. + public BicepOutputReference CollectorClientId => new("collectorClientId", this); + + /// + public override string GetBicepTemplateString() + { + try + { + var file = new FileInfo(DefinitionFile); + if (!file.Exists) + { + throw new FileNotFoundException("Export the model from Dashboard > Health and save it in the AppHost project.", DefinitionFile); + } + if (file.Length > HealthModelContract.MaxFileSize) + { + throw new InvalidDataException("The health model definition exceeds the supported file size."); + } + + var document = HealthModelContract.Deserialize(File.ReadAllText(DefinitionFile)); + ValidateBindings?.Invoke(document); + return HealthModelBicepGenerator.Generate(document); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or UnauthorizedAccessException) + { + throw new DistributedApplicationException( + $"Cannot publish health model '{Name}' from '{DefinitionFile}'. Export a valid version 1 definition from Dashboard > Health. {ex.Message}", ex); + } + } + + /// + public override BicepTemplateFile GetBicepTemplateFile(string? directory = null, bool deleteTemporaryFileOnDispose = true) + { + // Generate before creating the temporary directory so invalid definitions leave no artifacts. + var content = GetBicepTemplateString(); + var temporary = directory is null; + directory ??= Directory.CreateTempSubdirectory("aspire-health-model-").FullName; + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, $"{Name.ToLowerInvariant()}.module.bicep"); + File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return new BicepTemplateFile(path, temporary && deleteTemporaryFileOnDispose); + } +} diff --git a/src/Aspire.Hosting.Azure.HealthModels/HealthModelBicepGenerator.cs b/src/Aspire.Hosting.Azure.HealthModels/HealthModelBicepGenerator.cs new file mode 100644 index 00000000000..fa2e6725163 --- /dev/null +++ b/src/Aspire.Hosting.Azure.HealthModels/HealthModelBicepGenerator.cs @@ -0,0 +1,299 @@ +// 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 System.IO.Hashing; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.HealthModels; + +namespace Aspire.Hosting.Azure; + +internal static class HealthModelBicepGenerator +{ + internal const string HealthApiVersion = "2026-09-01-preview"; + internal const string MetricName = "aspire_health_status"; + internal const int MaximumSampleAgeSeconds = 180; + + internal static string Generate(HealthModelDocument document) + { + HealthModelContract.Validate(document); + var entities = new JsonArray(); + var signals = new JsonArray(); + foreach (var entity in document.Entities.OrderBy(e => e.Name, StringComparer.Ordinal)) + { + var assignments = new JsonArray(); + foreach (var signal in entity.LocalSignals.OrderBy(s => s.Name, StringComparer.Ordinal)) + { + if (signal.Kind != SignalKind.External || entity.AspireResourceName is null || entity.ReplicaIndex is null) + { + throw new InvalidDataException($"Entity '{entity.Name}' contains a signal without a supported local health metric binding."); + } + + var definitionName = StableName("signal", entity.Name, signal.Name); + signals.Add(new JsonObject + { + ["name"] = definitionName, + ["displayName"] = Truncate($"{entity.DisplayName}: {signal.Name}", 260), + ["queryText"] = CreateQuery(entity.AspireResourceName, entity.ReplicaIndex.Value, signal.Name) + }); + assignments.Add(new JsonObject + { + ["name"] = definitionName, + ["displayName"] = Truncate(signal.Name, 260), + ["signalKind"] = "PrometheusMetricsQuery", + ["signalDefinitionName"] = definitionName + }); + } + + var dependencies = new JsonObject + { + ["aggregationType"] = entity.Dependencies.AggregationType.ToString(), + ["ignoreUnknown"] = entity.Dependencies.IgnoreUnknown + }; + if (entity.Dependencies.AggregationType != DependenciesAggregationType.WorstOf) + { + dependencies["unit"] = entity.Dependencies.Unit.ToString(); + dependencies["unhealthyThreshold"] = entity.Dependencies.UnhealthyThreshold; + if (entity.Dependencies.DegradedThreshold is { } degraded) + { + dependencies["degradedThreshold"] = degraded; + } + } + entities.Add(new JsonObject + { + ["name"] = entity.Name, + ["displayName"] = entity.DisplayName, + ["canvasPosition"] = new JsonObject { ["x"] = entity.CanvasPosition.X, ["y"] = entity.CanvasPosition.Y }, + ["impact"] = entity.Impact.ToString(), + ["healthObjective"] = entity.HealthObjective, + ["dependencies"] = dependencies, + ["signals"] = assignments + }); + } + + var relationships = new JsonArray(document.Relationships + .OrderBy(r => r.ParentEntityName, StringComparer.Ordinal).ThenBy(r => r.ChildEntityName, StringComparer.Ordinal) + .Select(r => (JsonNode)new JsonObject + { + ["name"] = StableName("relationship", r.ParentEntityName, r.ChildEntityName), + ["parentEntityName"] = r.ParentEntityName, + ["childEntityName"] = r.ChildEntityName + }).ToArray()); + + // json() preserves fractional coordinates and thresholds; Bicep has no floating-point literal + // syntax. Do not round the browser's saved geometry just to fit Bicep's native int type. + return $$""" + targetScope = 'resourceGroup' + + @description('Azure region supporting Azure Monitor health models and managed Prometheus.') + param location string = resourceGroup().location + + @description('Health model name; the root entity is created with this name.') + param healthModelName string = {{Literal(document.Name)}} + + var definitionRootName = {{Literal(document.Name)}} + var prefix = '${take(healthModelName, 40)}-${uniqueString(resourceGroup().id, healthModelName)}' + var entityDefinitions = json({{Literal(entities.ToJsonString())}}) + var signalConfigurations = json({{Literal(signals.ToJsonString())}}) + var relationshipDefinitions = json({{Literal(relationships.ToJsonString())}}) + + resource workspace 'Microsoft.Monitor/accounts@2023-04-03' = { + name: '${prefix}-amw' + location: location + properties: { + publicNetworkAccess: 'Enabled' + } + } + + resource ingestionEndpoint 'Microsoft.Insights/dataCollectionEndpoints@2023-03-11' = { + name: '${prefix}-dce' + location: location + kind: 'Linux' + properties: { + networkAcls: { + publicNetworkAccess: 'Enabled' + } + } + } + + resource ingestionRule 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { + name: '${prefix}-dcr' + location: location + kind: 'Linux' + properties: { + dataCollectionEndpointId: ingestionEndpoint.id + dataSources: { + prometheusForwarder: [ + { + name: 'health-metrics' + streams: [ + 'Microsoft-PrometheusMetrics' + ] + } + ] + } + destinations: { + monitoringAccounts: [ + { + name: 'health-workspace' + accountResourceId: workspace.id + } + ] + } + dataFlows: [ + { + streams: [ + 'Microsoft-PrometheusMetrics' + ] + destinations: [ + 'health-workspace' + ] + } + ] + } + } + + resource collectorIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${prefix}-collector' + location: location + } + + resource metricsPublisher 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(ingestionRule.id, collectorIdentity.id, '3913510d-42f4-4e42-8a64-420c390055eb') + scope: ingestionRule + properties: { + principalId: collectorIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') + } + } + + resource healthModel 'Microsoft.CloudHealth/healthmodels@{{HealthApiVersion}}' = { + name: healthModelName + location: location + identity: { + type: 'SystemAssigned' + } + properties: {} + } + + resource modelReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(workspace.id, healthModel.id, '43d0d8ad-25c7-4714-9337-8ba259a9fe05') + scope: workspace + properties: { + principalId: healthModel.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '43d0d8ad-25c7-4714-9337-8ba259a9fe05') + } + } + + resource authentication 'Microsoft.CloudHealth/healthmodels/authenticationsettings@{{HealthApiVersion}}' = { + parent: healthModel + name: 'workspace-reader' + properties: { + authenticationKind: 'ManagedIdentity' + managedIdentityName: 'SystemAssigned' + } + } + + resource signalDefinitions 'Microsoft.CloudHealth/healthmodels/signaldefinitions@{{HealthApiVersion}}' = [for signal in signalConfigurations: { + parent: healthModel + name: signal.name + properties: { + displayName: signal.displayName + signalKind: 'PrometheusMetricsQuery' + queryText: signal.queryText + refreshInterval: 'PT1M' + timeGrain: 'PT1M' + evaluationRules: { + degradedRule: { + operator: 'LessThan' + threshold: 2 + } + unhealthyRule: { + operator: 'LessThan' + threshold: 1 + } + } + } + }] + + resource modelEntities 'Microsoft.CloudHealth/healthmodels/entities@{{HealthApiVersion}}' = [for entity in entityDefinitions: { + parent: healthModel + name: entity.name == definitionRootName ? healthModel.name : entity.name + properties: union({ + displayName: entity.displayName + canvasPosition: entity.canvasPosition + impact: entity.impact + signalGroups: union({ + dependencies: entity.dependencies + }, empty(entity.signals) ? {} : { + azureMonitorWorkspace: { + authenticationSetting: authentication.name + azureMonitorWorkspaceResourceId: workspace.id + signals: entity.signals + } + }) + }, entity.healthObjective == null ? {} : { + healthObjective: entity.healthObjective + }) + dependsOn: [ + modelReader + signalDefinitions + ] + }] + + resource modelRelationships 'Microsoft.CloudHealth/healthmodels/relationships@{{HealthApiVersion}}' = [for relationship in relationshipDefinitions: { + parent: healthModel + name: relationship.name + properties: { + parentEntityName: relationship.parentEntityName == definitionRootName ? healthModel.name : relationship.parentEntityName + childEntityName: relationship.childEntityName + } + dependsOn: [ + modelEntities + ] + }] + + output healthModelId string = healthModel.id + output workspaceId string = workspace.id + output dataCollectionEndpointId string = ingestionEndpoint.id + output dataCollectionRuleId string = ingestionRule.id + output remoteWriteEndpoint string = '${ingestionEndpoint.properties.metricsIngestion.endpoint}/dataCollectionRules/${ingestionRule.properties.immutableId}/streams/Microsoft-PrometheusMetrics/api/v1/write?api-version=2023-04-24' + output collectorIdentityId string = collectorIdentity.id + output collectorClientId string = collectorIdentity.properties.clientId + """; + } + + internal static string CreateQuery(string resourceName, int replicaIndex, string healthCheck) + { + var selector = $"{MetricName}{{resource_name=\"{PrometheusLabel(resourceName)}\",health_check=\"{PrometheusLabel(healthCheck)}\",replica_index=\"{replicaIndex.ToString(CultureInfo.InvariantCulture)}\"}}"; + // Preserve .NET HealthStatus (0 unhealthy, 1 degraded, 2 healthy). Negative/invalid samples and + // stale series produce no result, not a manufactured healthy value. PromQL comparisons without + // 'bool' retain the observed value so the reusable definition can apply both thresholds. + return $"min((({selector} >= 0) <= 2) and (time() - timestamp({selector}) < {MaximumSampleAgeSeconds}))"; + } + + private static string PrometheusLabel(string value) => value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal); + + private static string StableName(string prefix, string first, string second) + { + var value = JsonSerializer.Serialize(new[] { first, second }); + var hash = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(value)); + return $"{prefix}-{hash.ToString("x16", CultureInfo.InvariantCulture)}"; + } + + private static string Literal(string value) => "'" + value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("'", "\\'", StringComparison.Ordinal) + .Replace("${", "\\${", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + "'"; + + private static string Truncate(string value, int maximumLength) => value.Length <= maximumLength ? value : value[..maximumLength]; +} diff --git a/src/Aspire.Hosting.Azure.HealthModels/HealthModelBindingValidator.cs b/src/Aspire.Hosting.Azure.HealthModels/HealthModelBindingValidator.cs new file mode 100644 index 00000000000..1abdb384172 --- /dev/null +++ b/src/Aspire.Hosting.Azure.HealthModels/HealthModelBindingValidator.cs @@ -0,0 +1,75 @@ +// 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.Model; +using Aspire.HealthModels; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +internal static class HealthModelBindingValidator +{ + public static void Validate(HealthModelDocument document, IEnumerable resources, string applicationName) + { + if (document.ApplicationName != applicationName) + { + throw new InvalidDataException("The saved health model belongs to a different application. Export the definition from this AppHost."); + } + + var byName = resources.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase); + var bindings = document.Entities.Where(e => e.AspireResourceName is not null) + .ToLookup(e => e.AspireResourceName!, StringComparer.OrdinalIgnoreCase); + var expectedEdges = new HashSet(); + + foreach (var group in bindings) + { + if (!byName.TryGetValue(group.Key, out var resource)) + { + throw new InvalidDataException($"The saved health model references resource '{group.Key}', which is not in the AppHost."); + } + + var checks = resource.Annotations.OfType().Select(a => a.Key) + .Append("resource-state").ToHashSet(StringComparer.Ordinal); + foreach (var entity in group) + { + if (!checks.SetEquals(entity.LocalSignals.Select(s => s.Name))) + { + throw new InvalidDataException($"The saved health signals on resource '{group.Key}' differ from the AppHost checks. Re-export the model after adding, removing or renaming checks."); + } + } + + foreach (var relationship in resource.Annotations.OfType()) + { + AddEdges(group, bindings[relationship.Resource.Name], relationship.Type == KnownRelationshipTypes.Parent); + } + if (resource is IResourceWithParent child) + { + AddEdges(group, bindings[child.Parent.Name], reversed: true); + } + } + + var children = expectedEdges.Select(e => e.ChildEntityName).ToHashSet(StringComparer.Ordinal); + foreach (var entity in document.Entities.Where(e => e.Name != document.Name && !children.Contains(e.Name))) + { + expectedEdges.Add(new(document.Name, entity.Name)); + } + if (!expectedEdges.SetEquals(document.Relationships)) + { + throw new InvalidDataException("The saved health model topology differs from the AppHost dependencies. Re-export the model rather than publishing stale relationships."); + } + + void AddEdges(IEnumerable sources, IEnumerable targets, bool reversed) + { + foreach (var source in sources) + { + foreach (var target in targets) + { + if (source.Name != target.Name) + { + expectedEdges.Add(reversed ? new(target.Name, source.Name) : new(source.Name, target.Name)); + } + } + } + } + } +} diff --git a/src/Aspire.Hosting.Azure.HealthModels/README.md b/src/Aspire.Hosting.Azure.HealthModels/README.md new file mode 100644 index 00000000000..716c03364be --- /dev/null +++ b/src/Aspire.Hosting.Azure.HealthModels/README.md @@ -0,0 +1,144 @@ +# Azure Monitor health models hosting integration + +Use this integration to model, configure, and orchestrate Azure Monitor health-model publishing +and the managed Prometheus ingestion resources that supply its signals. + +## Getting started + +### Prerequisites + +This is an experimental, in-repository integration. Reference its project from an AppHost while +developing; the new package is not yet available from public package feeds. + +Publishing requires a version 1 `aspire-healthmodel.json` exported from Dashboard > Health, +the .NET SDK and Aspire CLI. It does not require Azure credentials or create Azure resources. +Deployment additionally requires an Azure subscription, a region supporting the current +CloudHealth API, Azure Container Apps, and permission to create resources and role assignments. + +After the package is released, add it from the AppHost directory with: + +```bash +aspire add Aspire.Hosting.Azure.HealthModels +``` + +## Usage example + +Then, in the AppHost, add a health-model publishing resource and an explicitly selected metrics producer: + +```csharp +#pragma warning disable ASPIREAZUREHEALTH001 + +var health = builder.AddAzureHealthModel("health", "aspire-healthmodel.json"); + +if (builder.ExecutionContext.IsPublishMode) +{ + builder.AddAzureContainerAppEnvironment("azure"); + + var metrics = builder.AddProject("health-metrics") + .WithHttpEndpoint(name: "http", targetPort: 8080) + .PublishAsAzureContainerApp((_, app) => + { + app.Template.Scale.MinReplicas = 1; + app.Template.Scale.MaxReplicas = 1; + }); + + builder.AddAzureContainerAppsHealthModelCollector( + "health-collector", health, metrics.GetEndpoint("http")); +} +``` + +For a TypeScript AppHost with an existing metrics producer exposing an `http` endpoint, the exported +methods have the same resource/endpoint contract: + +```typescript +const health = await builder.addAzureHealthModel("health", "aspire-healthmodel.json"); +await builder.addAzureContainerAppsHealthModelCollector( + "health-collector", health, metrics.getEndpoint("http")); +``` + +Configure the Container Apps environment and keep the metrics producer at one replica, as in the +C# example. The collector's endpoint references and identity values remain deferred expressions in the publish +artifacts. Its ingress is private, and it runs continuously at one replica. Do not expose the +metrics producer publicly merely to make it scrapeable. + +The API is publish-only: ordinary local startup neither reads the definition nor provisions Azure. +Invalid files, stale AppHost resource bindings, and changed relationships fail publishing explicitly. +The application name and complete set of health-check bindings must match the definition. + +## Published infrastructure + +The health-model module generates: + +- `Microsoft.CloudHealth/healthmodels` with a system-assigned query identity. +- The saved entities, directed relationships, canvas positions, impact and dependency policies. +- A reusable `PrometheusMetricsQuery` signal definition for every local signal and its assignment + to the matching entity's Azure Monitor workspace signal group. +- An Azure Monitor workspace (`Microsoft.Monitor/accounts`), data collection endpoint, and + data collection rule routing `Microsoft-PrometheusMetrics` to that workspace. +- A user-assigned collector identity with **Monitoring Metrics Publisher** scoped to the DCR. +- **Monitoring Reader** for the health model's identity, scoped to the workspace. + +CloudHealth resources target `2026-09-01-preview`. Other Azure resource API versions are pinned +independently. Fractional saved coordinates and thresholds are preserved through Bicep `json()`; +they are not rounded to integer literals. + +The model is a separate Azure resource, not a child of the workspace. Authentication uses managed +identity; no API keys or credential values are embedded in generated artifacts. Public-network +ingestion endpoints remain authenticated. Private-link configuration is outside this first version. + +## Signal contract + +The collector scrapes `/metrics` every 30 seconds. Producers report this Prometheus gauge: + +```text +aspire_health_status{resource_name="api",health_check="ready",replica_index="1"} 2 +``` + +`resource_name`, `health_check`, and `replica_index` must match the saved entity binding. +Values use .NET `HealthStatus`: **2 = Healthy, 1 = Degraded, 0 = Unhealthy**. A signal called +`resource-state` represents lifecycle health. It must not stand in for a readiness check. + +The generated AHM rules use `< 1` for Unhealthy and `< 2` for Degraded, with Unhealthy taking +precedence. Queries select only values in the supported 0–2 range and require a sample newer +than three minutes. Missing, invalid and stale measurements produce no query result rather +than a fabricated healthy value. Signal evaluation refreshes once per minute. +Empty-result/Unknown transitions and portal coordinate anchoring still need service-level validation; +preserving the definition does not yet prove identical Azure rendering or every evaluation edge case. + +An AppHost delegate is **not automatically executable in a deployed application**. The producer +must run the same underlying checks, or an explicitly equivalent validation, in the deployed +environment. The dedicated HealthModel playground demonstrates shared validation code between +its local AppHost and deployable metrics producer; its resources remain a simulation. + +## Publish, deploy and cleanup + +```powershell +aspire publish --apphost .\MyApp.AppHost.csproj --output-path .\publish-output --non-interactive +``` + +Review the Bicep, parameters and collector configuration before deployment. The integration uses +Aspire's existing Azure resource and Container Apps pipelines rather than executing Azure CLI +commands while constructing the app model. + +`aspire deploy` applies those resources and may incur charges. Identity/RBAC propagation can delay +initial samples. Verify the workspace contains the expected gauge series and the AHM entity +states match the intended validation after deployment. Compilation of Bicep alone does not prove +that a tenant has the required provider availability or ingestion permissions. + +The model, workspace, DCE/DCR and identities are owned by the generated Azure deployment. +Use the application's normal Azure teardown/resource-group cleanup workflow. Existing-workspace +reuse, sovereign-cloud endpoint selection and private-link provisioning are not yet exposed by this API. +Incremental deployments do not automatically delete child resources removed from the definition. +Use a fresh model or explicitly remove obsolete entities, relationships and signal definitions when +testing topology removals; deletion reconciliation is outside this first version. + +## Additional documentation + +* https://aspire.dev/integrations/gallery/ +* https://learn.microsoft.com/azure/azure-monitor/health-models/overview +* https://learn.microsoft.com/azure/azure-monitor/health-models/tutorial-bicep +* https://learn.microsoft.com/azure/azure-monitor/metrics/prometheus-remote-write + +## Feedback & contributing + +https://github.com/microsoft/aspire diff --git a/src/Aspire.Dashboard/Model/HealthModel/DependenciesAggregation.cs b/src/Shared/HealthModels/DependenciesAggregation.cs similarity index 92% rename from src/Aspire.Dashboard/Model/HealthModel/DependenciesAggregation.cs rename to src/Shared/HealthModels/DependenciesAggregation.cs index c7aa0aba115..a650749a6e6 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/DependenciesAggregation.cs +++ b/src/Shared/HealthModels/DependenciesAggregation.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Aspire.Dashboard.Model.HealthModel; +namespace Aspire.HealthModels; /// /// How an entity aggregates the health of its child entities into a single state. @@ -48,8 +48,8 @@ public sealed record DependenciesAggregation /// The strategy an entity uses to combine the health states of its children. /// /// -/// Values match the DependenciesAggregationType enum of the 2026-05-01-preview Azure API version. -/// BestOf exists only in later preview versions that have no Bicep types generated yet, so it is omitted. +/// The lite editor supports this subset of the 2026-09-01-preview Azure API's aggregation types. +/// BestOf is intentionally outside the initial editor's supported policies. /// public enum DependenciesAggregationType { diff --git a/src/Aspire.Dashboard/Model/HealthModel/EntityImpact.cs b/src/Shared/HealthModels/EntityImpact.cs similarity index 96% rename from src/Aspire.Dashboard/Model/HealthModel/EntityImpact.cs rename to src/Shared/HealthModels/EntityImpact.cs index 446213f9fe7..8f7a6038909 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/EntityImpact.cs +++ b/src/Shared/HealthModels/EntityImpact.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Aspire.Dashboard.Model.HealthModel; +namespace Aspire.HealthModels; /// /// Controls how much of a child entity's health state is propagated to its parents. diff --git a/src/Shared/HealthModels/HealthModelContract.cs b/src/Shared/HealthModels/HealthModelContract.cs new file mode 100644 index 00000000000..e54d292ec57 --- /dev/null +++ b/src/Shared/HealthModels/HealthModelContract.cs @@ -0,0 +1,171 @@ +// 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 System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Aspire.HealthModels; + +/// Serializes and validates the portable v1 health-model contract. +internal static partial class HealthModelContract +{ + /// The maximum size of an imported model document in UTF-8 bytes. + public const int MaxFileSize = 2 * 1024 * 1024; + + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 32, + Converters = { new JsonStringEnumConverter(allowIntegerValues: false) } + }; + + // Model and entity identifiers follow the Microsoft.CloudHealth resource naming constraints. + // Use an absolute end anchor so a trailing newline is not accepted as part of an identifier. + // https://learn.microsoft.com/azure/templates/microsoft.cloudhealth/2026-05-01-preview/healthmodels/entities + [GeneratedRegex("\\A[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]\\z", RegexOptions.CultureInvariant)] + private static partial Regex EntityNamePattern(); + + /// Serializes a model without changing its saved values or collection order. + public static string Serialize(HealthModelDocument document) => JsonSerializer.Serialize(document, s_jsonOptions); + + /// Reads and validates a portable model without requiring a running application. + public static HealthModelDocument Deserialize(string json) + { + ArgumentNullException.ThrowIfNull(json); + if (Encoding.UTF8.GetByteCount(json) > MaxFileSize) + { + throw new InvalidDataException("The model document exceeds the maximum file size."); + } + + // A model file has the shape { schemaVersion: 1, name, applicationName, entities: [...], + // relationships: [{ parentEntityName, childEntityName }] }. Unknown fields are rejected so + // runtime measurements or unsupported Azure settings cannot be silently discarded on import. + var document = JsonSerializer.Deserialize(json, s_jsonOptions) + ?? throw new InvalidDataException("The model document is null."); + Validate(document); + return document; + } + + /// Validates intrinsic configuration and the local v1 editor's graph restrictions. + public static void Validate(HealthModelDocument document) + { + ArgumentNullException.ThrowIfNull(document); + if (document.SchemaVersion != 1 || string.IsNullOrWhiteSpace(document.ApplicationName) || + document.Entities.IsDefaultOrEmpty || document.Entities.Length > 2000 || document.Relationships.IsDefault || + document.Relationships.Length > 10000 || string.IsNullOrEmpty(document.Name) || !EntityNamePattern().IsMatch(document.Name)) + { + throw new InvalidDataException("The model version, application, name or collection sizes are invalid."); + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var entity in document.Entities) + { + if (entity is null || string.IsNullOrEmpty(entity.Name) || !EntityNamePattern().IsMatch(entity.Name) || + !names.Add(entity.Name) || string.IsNullOrWhiteSpace(entity.DisplayName) || entity.DisplayName.Length > 260 || + entity.CanvasPosition is null || !IsValidPosition(entity.CanvasPosition) || + !Enum.IsDefined(entity.Impact) || entity.Dependencies is null || entity.LocalSignals.IsDefault || + entity.LocalSignals.Length > 1000 || + entity.Name != document.Name && (string.IsNullOrEmpty(entity.AspireResourceName) || entity.ReplicaIndex is null) || + entity.ReplicaIndex is < 0 || entity.HealthObjective is { } objective && (!double.IsFinite(objective) || objective < 0 || objective > 100)) + { + throw new InvalidDataException("An entity has an invalid name, binding, position, impact or health objective."); + } + + var signalNames = new HashSet(StringComparer.Ordinal); + foreach (var signal in entity.LocalSignals) + { + if (signal is null || string.IsNullOrEmpty(signal.Name) || !Enum.IsDefined(signal.Kind) || !signalNames.Add(signal.Name)) + { + throw new InvalidDataException("An entity has an invalid or duplicate local signal binding."); + } + } + ValidateAggregation(entity.Dependencies); + } + + if (!names.Contains(document.Name) || document.Entities.Single(e => e.Name == document.Name).Impact != EntityImpact.Standard || + document.Relationships.Any(r => r is null || r.ChildEntityName == document.Name || + !names.Contains(r.ParentEntityName) || !names.Contains(r.ChildEntityName))) + { + throw new InvalidDataException("The model must have a standard-impact root with valid relationships and no parent."); + } + + var incoming = names.ToDictionary(name => name, _ => 0, StringComparer.Ordinal); + var seen = new HashSet(); + foreach (var relationship in document.Relationships) + { + if (!seen.Add(relationship)) + { + throw new InvalidDataException("The health model contains a dangling or duplicate relationship."); + } + incoming[relationship.ChildEntityName]++; + } + + // DAG propagation and root reachability are local v1 editor constraints, not Azure restrictions. + // Traverse the contract directly so importing or publishing does not require dashboard runtime types. + var children = document.Relationships.ToLookup(r => r.ParentEntityName, r => r.ChildEntityName, StringComparer.Ordinal); + var ready = new Queue(document.Entities.Where(e => incoming[e.Name] == 0).Select(e => e.Name)); + var reachable = new HashSet(StringComparer.Ordinal) { document.Name }; + var visited = 0; + while (ready.TryDequeue(out var name)) + { + visited++; + foreach (var child in children[name]) + { + if (reachable.Contains(name)) + { + reachable.Add(child); + } + if (--incoming[child] == 0) + { + ready.Enqueue(child); + } + } + } + + if (visited != names.Count) + { + throw new InvalidDataException("Health model relationships must not contain a cycle."); + } + if (reachable.Count != names.Count) + { + throw new InvalidDataException("All entities must be reachable from the model root."); + } + } + + /// Checks that canvas coordinates are finite and within the local editor's bounds. + public static bool IsValidPosition(HealthModelCanvasPosition position) => + double.IsFinite(position.X) && double.IsFinite(position.Y) && + Math.Abs(position.X) <= 1_000_000 && Math.Abs(position.Y) <= 1_000_000; + + /// Validates dependency aggregation settings independently of the model graph. + public static void ValidateAggregation(DependenciesAggregation aggregation) + { + ArgumentNullException.ThrowIfNull(aggregation); + if (!Enum.IsDefined(aggregation.AggregationType) || !Enum.IsDefined(aggregation.Unit)) + { + throw new InvalidDataException("The dependency aggregation type or unit is invalid."); + } + if (aggregation.AggregationType == DependenciesAggregationType.WorstOf) + { + if (aggregation.DegradedThreshold is not null || aggregation.UnhealthyThreshold is not null) + { + throw new InvalidDataException("Worst-of rollup does not accept thresholds."); + } + return; + } + + if (aggregation.UnhealthyThreshold is not { } unhealthy || !ValidThreshold(unhealthy) || + aggregation.DegradedThreshold is { } degraded && (!ValidThreshold(degraded) || + (aggregation.AggregationType == DependenciesAggregationType.MinHealthy ? degraded <= unhealthy : degraded >= unhealthy))) + { + throw new InvalidDataException("Set an unhealthy threshold and order the thresholds from degraded to unhealthy."); + } + + bool ValidThreshold(double value) => double.IsFinite(value) && value >= 0 && + (aggregation.Unit == AggregationUnit.Percentage ? value <= 100 : value == Math.Truncate(value)); + } +} diff --git a/src/Shared/HealthModels/HealthModelDocument.cs b/src/Shared/HealthModels/HealthModelDocument.cs new file mode 100644 index 00000000000..887d739216f --- /dev/null +++ b/src/Shared/HealthModels/HealthModelDocument.cs @@ -0,0 +1,86 @@ +// 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.Immutable; +using System.Text.Json.Serialization; + +namespace Aspire.HealthModels; + +/// Coordinates in the model's canvas space, independent of zoom and viewport size. +/// The horizontal coordinate. +/// The vertical coordinate. +public sealed record HealthModelCanvasPosition(double X, double Y); + +/// A local signal binding, without measurements, descriptions, or exception data. +/// The local signal name, unique within its entity. +/// The signal's data source. +public sealed record HealthModelSignalBinding(string Name, SignalKind Kind); + +/// Portable configuration for an entity; observed health is deliberately excluded. +public sealed record HealthModelEntityConfiguration +{ + /// Gets the entity identifier used by relationships. + public required string Name { get; init; } + + /// Gets the name shown in the designer. + public required string DisplayName { get; init; } + + /// Gets the bound AppHost resource name, without the runtime-generated instance suffix. + public string? AspireResourceName { get; init; } + + /// Gets the replica index of the bound AppHost resource. + public int? ReplicaIndex { get; init; } + + /// Gets the saved position in the model's canvas space. + public required HealthModelCanvasPosition CanvasPosition { get; init; } + + /// Gets how much of this entity's health is propagated to its parents. + public EntityImpact Impact { get; init; } + + /// Gets the target percentage of time this entity is expected to be healthy. + public double? HealthObjective { get; init; } + + /// Gets how this entity combines the health of its children. + public DependenciesAggregation Dependencies { get; init; } = DependenciesAggregation.WorstOf; + + /// Gets the local signal bindings, without observed state or telemetry. + public ImmutableArray LocalSignals { get; init; } = []; +} + +/// +/// A directed parent-to-child edge in a health model. +/// +/// +/// Azure models relationships as standalone resources with immutable parentEntityName and +/// childEntityName, and carries no health or aggregation configuration on the edge itself. Rollup +/// tuning lives on the two entities instead: on the child +/// and on the parent. +/// +/// The name of the parent entity. +/// The name of the child entity. +public sealed record HealthModelRelationship(string ParentEntityName, string ChildEntityName); + +/// A versioned, portable model definition for saved layout and publishing integration. +/// +/// This is not an ARM template. Entity names, relationships, impact, dependency settings and canvas +/// coordinates map to Microsoft.CloudHealth entities. Local signal bindings still require an Azure +/// metric/query mapping or an external signal producer when a publisher consumes this definition. +/// +public sealed record HealthModelDocument +{ + /// Gets the portable schema version, which must be explicitly present in JSON. + [JsonRequired] + public int SchemaVersion { get; init; } = 1; + + /// Gets the model identifier, which is also the root entity's identifier. + public required string Name { get; init; } + + /// Gets the application name used to associate a saved model with its AppHost. + public required string ApplicationName { get; init; } + + /// Gets all entities, including the root, in their saved order. + public required ImmutableArray Entities { get; init; } + + /// Gets the parent-to-child edges in their saved order. + public required ImmutableArray Relationships { get; init; } +} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthState.cs b/src/Shared/HealthModels/HealthState.cs similarity index 91% rename from src/Aspire.Dashboard/Model/HealthModel/HealthState.cs rename to src/Shared/HealthModels/HealthState.cs index e685ed4ffbe..b187909ec89 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthState.cs +++ b/src/Shared/HealthModels/HealthState.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Aspire.Dashboard.Model.HealthModel; +namespace Aspire.HealthModels; /// /// The health state of an entity or signal in a health model. @@ -20,14 +20,14 @@ namespace Aspire.Dashboard.Model.HealthModel; public enum HealthState { /// No signal has reported yet, or the entity has no signals to evaluate. - Unknown, + Unknown = 0, /// All signals are within their expected range. - Healthy, + Healthy = 1, /// At least one signal breached its degraded threshold but not its unhealthy threshold. - Degraded, + Degraded = 2, /// At least one signal breached its unhealthy threshold. - Unhealthy + Unhealthy = 3 } diff --git a/src/Shared/HealthModels/SignalKind.cs b/src/Shared/HealthModels/SignalKind.cs new file mode 100644 index 00000000000..3588a9c492a --- /dev/null +++ b/src/Shared/HealthModels/SignalKind.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.HealthModels; + +/// +/// The data source a signal reads from. +/// +/// +/// Mirrors the SignalKind discriminator in Azure Monitor health models. Signals produced locally by the +/// dashboard use because, like Azure external signals, their state is reported by the +/// app host rather than computed by the health model service from a metric or query. +/// +public enum SignalKind +{ + /// A platform metric read from an Azure resource. + AzureResourceMetric, + + /// A KQL query run against a Log Analytics workspace. + LogAnalyticsQuery, + + /// A PromQL query run against an Azure Monitor workspace. + PrometheusMetricsQuery, + + /// A state reported by an external producer rather than evaluated by the health model itself. + External +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj index 373f506a8e1..f1763f6f56e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj +++ b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj @@ -40,6 +40,7 @@ + diff --git a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj index 82321ea8ff6..0859d538fc1 100644 --- a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj +++ b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj @@ -52,12 +52,16 @@ + + + diff --git a/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs b/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs index 79e3e40dbbe..c5017f26660 100644 --- a/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs @@ -5,6 +5,7 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.HealthModel; using Aspire.Tests.Shared.DashboardModel; +using HealthModelPlayground; using Microsoft.Extensions.Diagnostics.HealthChecks; using VerifyXunit; using Xunit; @@ -55,6 +56,29 @@ public void DocumentRoundTripPreservesExactPositionsAndPropagation() Assert.Equal(changed.Name, rebound.Name); } + [Fact] + public void PublishedPlaygroundDefinitionMatchesDashboardIdentitiesAndLayout() + { + var resources = HealthModelScenario.Resources.Select(resource => + { + var relationships = resource.Dependencies.Select(dependency => new RelationshipViewModel(dependency, KnownRelationshipTypes.Reference)).ToList(); + if (resource.ParentName is { } parent) + { + relationships.Add(new(parent, KnownRelationshipTypes.Parent)); + } + return ModelTestHelpers.CreateResource(resource.Name, state: KnownResourceState.Running, + relationships: [.. relationships], replicaIndex: 1, + healthReports: [new(resource.HealthCheckName, resource.HealthStatus, null, null)]); + }); + var current = HealthModelDocuments.Create(AspireHealthModelBuilder.Build(resources), "HealthModelSandbox.AppHost"); + var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestData", "healthmodel-playground.json")); + var imported = HealthModelDocuments.Deserialize(json, current); + + Assert.Equal( + current.Entities.OrderBy(entity => entity.Name).Select(entity => (entity.Name, entity.CanvasPosition, entity.Impact, entity.Dependencies)), + imported.Entities.OrderBy(entity => entity.Name).Select(entity => (entity.Name, entity.CanvasPosition, entity.Impact, entity.Dependencies))); + } + [Fact] public void ImportRejectsAnotherApplicationOrChangedTopology() { diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs index 26e10118d58..97e54941432 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceGraphMapperTests.cs @@ -4,7 +4,6 @@ using System.Collections.Immutable; using System.Xml.Linq; using Aspire.Dashboard.Model; -using Aspire.Dashboard.Model.HealthModel; using Aspire.Dashboard.Model.ResourceGraph; using Aspire.Dashboard.Resources; using Aspire.Tests.Shared.DashboardModel; diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index c603526a3c6..e205182e28c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -26,6 +26,7 @@ + @@ -52,6 +53,10 @@ + + + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureHealthModelTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureHealthModelTests.cs new file mode 100644 index 00000000000..2dab6526605 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureHealthModelTests.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREAZUREHEALTH001, ASPIREAZURE001 + +using Aspire.HealthModels; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using HealthModelPlayground; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureHealthModelTests(ITestOutputHelper output) +{ + [Fact] + public void RunModeDoesNotReadDefinitionOrProvisionAzure() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var model = builder.AddAzureHealthModel("health", "not-created-yet.json"); + Assert.Empty(builder.Resources.OfType()); + Assert.Empty(builder.Resources.OfType()); + Assert.Equal("health", model.Resource.Name); + } + + [Fact] + public async Task PublishPreservesTheModelAndCreatesMonitoringResources() + { + using var workspace = TemporaryWorkspace.Create(output); + var definitionFile = Path.Combine(workspace.Path, "healthmodel.json"); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + await File.WriteAllTextAsync(definitionFile, HealthModelContract.Serialize(CreateDocument(builder.Environment.ApplicationName)), TestContext.Current.CancellationToken); + builder.AddContainer("api", "sample-api").WithHealthCheck("api_check"); + var model = builder.AddAzureHealthModel("health", definitionFile); + using var app = builder.Build(); + Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var manifest = await ManifestUtils.GetManifest(model.Resource, workspace.Path); + var bicepPath = Path.Combine(workspace.Path, "health.module.bicep"); + var bicep = await File.ReadAllTextAsync(bicepPath, TestContext.Current.CancellationToken); + + await Verify(manifest.ToJsonString(), "json").AppendContentAsFile(bicep, "bicep"); + } + + [Fact] + public void MissingDefinitionFailsPublishWithActionableError() + { + using var workspace = TemporaryWorkspace.Create(output); + var resource = new AzureHealthModelResource("health", Path.Combine(workspace.Path, "missing.json")); + var exception = Assert.Throws(resource.GetBicepTemplateString); + Assert.Contains("Dashboard > Health", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void InvalidVersionDoesNotGenerateAHealthyFallbackModel() + { + using var workspace = TemporaryWorkspace.Create(output); + var path = Path.Combine(workspace.Path, "invalid.json"); + File.WriteAllText(path, HealthModelContract.Serialize(CreateDocument("SampleApp") with { SchemaVersion = 99 })); + var resource = new AzureHealthModelResource("health", path); + + Assert.Throws(resource.GetBicepTemplateString); + } + + [Fact] + public Task PrometheusQueryPreservesTriStateAndEscapesLabelValues() + { + var query = HealthModelBicepGenerator.CreateQuery("api\"\\name", 1, "readiness\ncheck"); + return Verify(query, "txt"); + } + + [Fact] + public void OutputReferencesRemainDeferred() + { + var resource = new AzureHealthModelResource("health", Path.GetFullPath("definition.json")); + Assert.Equal("{health.outputs.workspaceId}", resource.WorkspaceId.ValueExpression); + Assert.Equal("{health.outputs.remoteWriteEndpoint}", resource.RemoteWriteEndpoint.ValueExpression); + Assert.Equal("{health.outputs.collectorIdentityId}", resource.CollectorIdentityId.ValueExpression); + Assert.Equal("{health.outputs.collectorClientId}", resource.CollectorClientId.ValueExpression); + } + + [Fact] + public void CollectorIsNotAddedInRunMode() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var metrics = builder.AddContainer("metrics", "test-metrics").WithHttpEndpoint(targetPort: 8080); + var model = builder.AddAzureHealthModel("health", "not-created.json"); + builder.AddAzureContainerAppsHealthModelCollector("collector", model, metrics.GetEndpoint("http")); + + Assert.Equal(metrics.Resource, Assert.Single(builder.Resources)); + } + + [Fact] + public async Task CollectorPublishesManagedIdentityAndDeferredIngestionConfiguration() + { + using var workspace = TemporaryWorkspace.Create(output); + var definitionFile = Path.Combine(workspace.Path, "healthmodel.json"); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + await File.WriteAllTextAsync(definitionFile, HealthModelContract.Serialize(CreateDocument(builder.Environment.ApplicationName)), TestContext.Current.CancellationToken); + builder.AddAzureContainerAppEnvironment("env"); + builder.AddContainer("api", "test-api").WithHealthCheck("api_check"); + var metrics = builder.AddContainer("metrics", "test-metrics").WithHttpEndpoint(targetPort: 8080); + var health = builder.AddAzureHealthModel("health", definitionFile); + var collector = builder.AddAzureContainerAppsHealthModelCollector("collector", health, metrics.GetEndpoint("http")); + using var app = builder.Build(); + await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, TestContext.Current.CancellationToken); + var target = Assert.IsAssignableFrom(collector.Resource.GetDeploymentTargetAnnotation()!.DeploymentTarget); + var manifest = await ManifestUtils.GetManifest(target, workspace.Path); + + await Verify(manifest.ToJsonString(), "json").AppendContentAsFile(target.GetBicepTemplateString(), "bicep"); + } + + [Fact] + public void StaleResourceOrHealthCheckBindingFailsExplicitly() + { + using var workspace = TemporaryWorkspace.Create(output); + var definitionFile = Path.Combine(workspace.Path, "healthmodel.json"); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + File.WriteAllText(definitionFile, HealthModelContract.Serialize(CreateDocument(builder.Environment.ApplicationName))); + var model = builder.AddAzureHealthModel("health", definitionFile); + + Assert.Throws(model.Resource.GetBicepTemplateString); + builder.AddContainer("api", "test-api").WithHealthCheck("renamed-check"); + Assert.Throws(model.Resource.GetBicepTemplateString); + } + + [Fact] + public void AddedHealthChecksCannotBeSilentlyOmitted() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddContainer("api", "test-api").WithHealthCheck("api_check").WithHealthCheck("new-readiness"); + var document = CreateDocument(builder.Environment.ApplicationName); + + Assert.Throws(() => HealthModelBindingValidator.Validate(document, builder.Resources, builder.Environment.ApplicationName)); + } + + [Fact] + public void DefinitionFromAnotherApplicationIsRejected() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddContainer("api", "test-api").WithHealthCheck("api_check"); + var document = CreateDocument("AnotherApplication"); + + Assert.Throws(() => HealthModelBindingValidator.Validate(document, builder.Resources, builder.Environment.ApplicationName)); + } + + [Fact] + public void PlaygroundDefinitionMatchesTheSharedScenario() + { + var document = HealthModelContract.Deserialize(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestData", "healthmodel-playground.json"))); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var resources = HealthModelScenario.Resources.ToDictionary(resource => resource.Name, + resource => builder.AddContainer(resource.Name, "test-resource").WithHealthCheck(resource.HealthCheckName)); + foreach (var resource in HealthModelScenario.Resources) + { + if (resource.ParentName is { } parent) + { + resources[resource.Name].WithParentRelationship(resources[parent]); + } + foreach (var dependency in resource.Dependencies) + { + resources[resource.Name].WithReferenceRelationship(resources[dependency]); + } + } + + HealthModelBindingValidator.Validate(document, builder.Resources, "HealthModelSandbox.AppHost"); + Assert.Equal(12, document.Entities.Length); + Assert.Equal(11, document.Relationships.Length); + Assert.Equal(22, document.Entities.Sum(entity => entity.LocalSignals.Length)); + Assert.All(document.Entities.Where(entity => entity.AspireResourceName is not null), + entity => Assert.Equal(1, entity.ReplicaIndex)); + + var rewired = document with + { + Relationships = [.. document.Relationships.Select(relationship => + relationship.ChildEntityName == document.Entities.Single(entity => entity.AspireResourceName == "orders-db").Name + ? relationship with { ParentEntityName = document.Name } + : relationship)] + }; + Assert.Throws(() => HealthModelBindingValidator.Validate(rewired, builder.Resources, document.ApplicationName)); + } + + private static HealthModelDocument CreateDocument(string applicationName) => new() + { + Name = "sample-health", + ApplicationName = applicationName, + Entities = + [ + new() + { + Name = "sample-health", + DisplayName = "AppHost", + CanvasPosition = new(0, 0) + }, + new() + { + Name = "resource-api", + DisplayName = "API's ${literal} label", + AspireResourceName = "api", + ReplicaIndex = 1, + CanvasPosition = new(224.25, -180.5), + Impact = EntityImpact.Limited, + HealthObjective = 99.95, + Dependencies = new() + { + AggregationType = DependenciesAggregationType.MaxNotHealthy, + Unit = AggregationUnit.Percentage, + UnhealthyThreshold = 75.5, + DegradedThreshold = 25 + }, + LocalSignals = [new("resource-state", SignalKind.External), new("api_check", SignalKind.External)] + } + ], + Relationships = [new("sample-health", "resource-api")] + }; +} diff --git a/tests/Aspire.Hosting.Azure.Tests/HealthModelMetricTests.cs b/tests/Aspire.Hosting.Azure.Tests/HealthModelMetricTests.cs new file mode 100644 index 00000000000..1e14dc44222 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/HealthModelMetricTests.cs @@ -0,0 +1,258 @@ +// 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 System.Text.RegularExpressions; +using HealthModelPlayground; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Hosting.Azure.Tests; + +public class HealthModelMetricTests +{ + [Fact] + public void ScenarioPreservesTheLocalTopologyAndValidators() + { + Assert.Collection(HealthModelScenario.Resources, + resource => AssertResource(resource, "storefront", "Serving customers.", HealthStatus.Healthy, null, null, "checkout-api", "catalog-api", "identity-api"), + resource => AssertResource(resource, "checkout-api", "Accepting orders.", HealthStatus.Healthy, null, null, "orders-db-server", "payments-gateway"), + resource => AssertResource(resource, "orders-db-server", "Accepting connections.", HealthStatus.Healthy, null, null), + resource => AssertResource(resource, "orders-db", "Migrations applied.", HealthStatus.Healthy, "orders-db-server", null), + resource => AssertResource(resource, "payments-gateway", "Elevated latency from the payment provider.", HealthStatus.Degraded, null, null), + resource => AssertResource(resource, "catalog-api", "Serving product data.", HealthStatus.Healthy, null, null, "catalog-db-server", "search-index"), + resource => AssertResource(resource, "catalog-db-server", "Accepting connections.", HealthStatus.Healthy, null, null), + resource => AssertResource(resource, "catalog-db", "Migrations applied.", HealthStatus.Healthy, "catalog-db-server", null), + resource => AssertResource(resource, "search-index", "Index rebuild failed.", HealthStatus.Unhealthy, null, "Shard 3 is offline."), + resource => AssertResource(resource, "identity-api", "Issuing tokens.", HealthStatus.Healthy, null, null, "identity-cache"), + resource => AssertResource(resource, "identity-cache", "Cache warm.", HealthStatus.Healthy, null, null)); + } + + [Theory] + [InlineData("not-a-resource")] + [InlineData("Storefront")] + [InlineData("")] + public void UnknownResourceDoesNotEvaluateAsHealthy(string resourceName) + { + Assert.Throws(() => HealthModelScenario.Evaluate(resourceName)); + } + + [Fact] + public async Task MetricsRepresentRegisteredChecksWithoutDependencyAggregation() + { + var services = new ServiceCollection(); + services.AddLogging(); + HealthModelScenario.AddHealthChecks(services); + using var provider = services.BuildServiceProvider(); + + var report = await provider.GetRequiredService() + .CheckHealthAsync(TestContext.Current.CancellationToken); + + Assert.Equal(HealthModelScenario.Resources.Count, report.Entries.Count); + Assert.Equal(HealthStatus.Unhealthy, report.Status); + foreach (var resource in HealthModelScenario.Resources) + { + var expected = HealthModelScenario.Evaluate(resource.Name); + var actual = report.Entries[resource.HealthCheckName]; + Assert.Equal(expected.Status, actual.Status); + Assert.Equal(expected.Description, actual.Description); + Assert.Equal(expected.Exception?.Message, actual.Exception?.Message); + } + + Assert.Equal( + [ + ("catalog-api", "catalog-api_check", 2), + ("catalog-api", "resource-state", 2), + ("catalog-db", "catalog-db_check", 2), + ("catalog-db", "resource-state", 2), + ("catalog-db-server", "catalog-db-server_check", 2), + ("catalog-db-server", "resource-state", 2), + ("checkout-api", "checkout-api_check", 2), + ("checkout-api", "resource-state", 2), + ("identity-api", "identity-api_check", 2), + ("identity-api", "resource-state", 2), + ("identity-cache", "identity-cache_check", 2), + ("identity-cache", "resource-state", 2), + ("orders-db", "orders-db_check", 2), + ("orders-db", "resource-state", 2), + ("orders-db-server", "orders-db-server_check", 2), + ("orders-db-server", "resource-state", 2), + ("payments-gateway", "payments-gateway_check", 1), + ("payments-gateway", "resource-state", 2), + ("search-index", "search-index_check", 0), + ("search-index", "resource-state", 2), + ("storefront", "storefront_check", 2), + ("storefront", "resource-state", 2) + ], + ReadSamples(HealthModelMetrics.Format(report, HealthModelScenario.Resources))); + } + + [Theory] + [InlineData(HealthStatus.Healthy, 2)] + [InlineData(HealthStatus.Degraded, 1)] + [InlineData(HealthStatus.Unhealthy, 0)] + public void MetricsUseReportedStatusInsteadOfExpectedStatusAndDoNotExposeDetails(HealthStatus status, int expectedValue) + { + var resource = HealthModelScenario.Resources.Single(resource => resource.Name == "storefront"); + var report = new HealthReport(new Dictionary + { + [resource.HealthCheckName] = new( + status, + "Private check description", + TimeSpan.FromSeconds(1), + new InvalidOperationException("Private exception"), + new Dictionary { ["secret"] = "Private data" }) + }, TimeSpan.FromSeconds(1)); + + Assert.Equal( + [ + ("storefront", "storefront_check", expectedValue), + ("storefront", "resource-state", 2) + ], + ReadSamples(HealthModelMetrics.Format(report, [resource]))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MissingCheckDoesNotManufactureAHealthyReading(bool includeUnrelatedCheck) + { + var resource = HealthModelScenario.Resources.Single(resource => resource.Name == "storefront"); + var entries = new Dictionary(); + if (includeUnrelatedCheck) + { + entries["unrelated_check"] = new(HealthStatus.Healthy, null, TimeSpan.Zero, null, null); + } + + var report = new HealthReport(entries, TimeSpan.Zero); + + Assert.Equal( + [("storefront", "resource-state", 2)], + ReadSamples(HealthModelMetrics.Format(report, [resource]))); + } + + [Fact] + public void LabelsEscapeQuotesBackslashesAndLineFeeds() + { + var resource = new HealthModelScenarioResource("api\"\\name\nnext", "Private description", HealthStatus.Unhealthy, null, [], null); + var report = new HealthReport(new Dictionary + { + [resource.HealthCheckName] = new(HealthStatus.Unhealthy, null, TimeSpan.Zero, null, null) + }, TimeSpan.Zero); + + Assert.Equal( + [ + ("""api\"\\name\nnext""", """api\"\\name\nnext_check""", 0), + ("""api\"\\name\nnext""", "resource-state", 2) + ], + ReadSamples(HealthModelMetrics.Format(report, [resource]))); + } + + [Theory] + [InlineData("en-US")] + [InlineData("fr-FR")] + [InlineData("ar-SA")] + [InlineData("tr-TR")] + public void MetricsUseInvariantNumbersAndOrdinalOrdering(string culture) + { + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + HealthModelScenarioResource[] resources = + [ + new("ä", "", HealthStatus.Healthy, null, [], null), + new("z", "", HealthStatus.Degraded, null, [], null), + new("I", "", HealthStatus.Unhealthy, null, [], null) + ]; + var entries = resources.ToDictionary( + resource => resource.HealthCheckName, + resource => new HealthReportEntry(resource.HealthStatus, null, TimeSpan.Zero, null, null)); + var report = new HealthReport(entries, TimeSpan.Zero); + + (string, string, int)[] expected = + [ + ("I", "I_check", 0), + ("I", "resource-state", 2), + ("z", "z_check", 1), + ("z", "resource-state", 2), + ("ä", "ä_check", 2), + ("ä", "resource-state", 2) + ]; + + Assert.Equal(expected, ReadSamples(HealthModelMetrics.Format(report, resources))); + Assert.Equal(expected, ReadSamples(HealthModelMetrics.Format(report, resources.AsEnumerable().Reverse()))); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + [Fact] + public async Task SharedChecksHonorCancellation() + { + var services = new ServiceCollection(); + services.AddLogging(); + HealthModelScenario.AddHealthChecks(services); + using var provider = services.BuildServiceProvider(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + provider.GetRequiredService().CheckHealthAsync(cancellation.Token)); + } + + private static void AssertResource( + HealthModelScenarioResource resource, + string name, + string description, + HealthStatus status, + string? parentName, + string? exceptionMessage, + params string[] dependencies) + { + Assert.Equal(name, resource.Name); + Assert.Equal($"{name}_check", resource.HealthCheckName); + Assert.Equal(description, resource.Description); + Assert.Equal(status, resource.HealthStatus); + Assert.Equal(parentName, resource.ParentName); + Assert.Equal(exceptionMessage, resource.ExceptionMessage); + Assert.Equal(dependencies, resource.Dependencies); + + var result = HealthModelScenario.Evaluate(name); + Assert.Equal(status, result.Status); + Assert.Equal(description, result.Description); + if (exceptionMessage is null) + { + Assert.Null(result.Exception); + } + else + { + Assert.Equal(exceptionMessage, Assert.IsType(result.Exception).Message); + } + } + + private static (string ResourceName, string HealthCheck, int Value)[] ReadSamples(string text) + { + var lines = text.Split('\n'); + Assert.Equal("# HELP aspire_health_status Simulated resource health: 0 = Unhealthy, 1 = Degraded, 2 = Healthy.", lines[0]); + Assert.Equal("# TYPE aspire_health_status gauge", lines[1]); + Assert.Equal("", lines[^1]); + + // Parse the entire exposition line, including the exact label set and order. + // E.g. aspire_health_status{resource_name="api\"\\name\nnext",health_check="api_check",replica_index="1"} 0 + // Only Prometheus's three label escapes are allowed; extra labels, timestamps, + // descriptions, raw line feeds, and numeric culture artifacts all fail this grammar. + return lines[2..^1].Select(line => + { + var match = Regex.Match( + line, + """\Aaspire_health_status\{resource_name="((?:[^"\\\r\n]|\\["\\n])*)",health_check="((?:[^"\\\r\n]|\\["\\n])*)",replica_index="1"\} ([012])\z""", + RegexOptions.CultureInvariant); + Assert.True(match.Success, $"Invalid metric line: {line}"); + + return (match.Groups[1].Value, match.Groups[2].Value, int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture)); + }).ToArray(); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.CollectorPublishesManagedIdentityAndDeferredIngestionConfiguration.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.CollectorPublishesManagedIdentityAndDeferredIngestionConfiguration.verified.bicep new file mode 100644 index 00000000000..8f5d24461e5 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.CollectorPublishesManagedIdentityAndDeferredIngestionConfiguration.verified.bicep @@ -0,0 +1,62 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param health_outputs_remotewriteendpoint string + +param health_outputs_collectorclientid string + +param health_outputs_collectoridentityid string + +resource collector 'Microsoft.App/containerApps@2025-07-01' = { + name: 'collector' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: 'prom/prometheus:v3.5.0' + name: 'collector' + command: [ + '/bin/sh' + ] + args: [ + '-ec' + ': "\${METRICS_ENDPOINT:?A metrics endpoint is required}"\n: "\${REMOTE_WRITE_ENDPOINT:?An Azure remote-write endpoint is required}"\n: "\${AZURE_CLIENT_ID:?The collector managed identity client ID is required}"\ncase "\$METRICS_ENDPOINT" in\n https://*) scheme=https; target="\${METRICS_ENDPOINT#https://}" ;;\n http://*) scheme=http; target="\${METRICS_ENDPOINT#http://}" ;;\n *) echo "METRICS_ENDPOINT must be an HTTP or HTTPS endpoint" >&2; exit 1 ;;\nesac\ntarget="\${target%/}"\nprintf \'%s\' "\$target" | grep -Eq \'^[a-zA-Z0-9._:-]+\$\' || { echo "Invalid metrics target" >&2; exit 1; }\nprintf \'%s\' "\$AZURE_CLIENT_ID" | grep -Eq \'^[a-fA-F0-9-]+\$\' || { echo "Invalid managed identity client ID" >&2; exit 1; }\nprintf \'%s\' "\$REMOTE_WRITE_ENDPOINT" | grep -Eq \'^https://[a-zA-Z0-9.-]+/[a-zA-Z0-9/_?=.&%-]+\$\' || { echo "Invalid remote-write endpoint" >&2; exit 1; }\ncat > /tmp/health-prometheus.yml <= 0) <= 2) and (time() - timestamp(aspire_health_status{resource_name="api\"\\name",health_check="readiness\ncheck",replica_index="1"}) < 180)) \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.bicep new file mode 100644 index 00000000000..e2971bcb7e4 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.bicep @@ -0,0 +1,179 @@ +targetScope = 'resourceGroup' + +@description('Azure region supporting Azure Monitor health models and managed Prometheus.') +param location string = resourceGroup().location + +@description('Health model name; the root entity is created with this name.') +param healthModelName string = 'sample-health' + +var definitionRootName = 'sample-health' +var prefix = '${take(healthModelName, 40)}-${uniqueString(resourceGroup().id, healthModelName)}' +var entityDefinitions = json('[{"name":"resource-api","displayName":"API\\u0027s \${literal} label","canvasPosition":{"x":224.25,"y":-180.5},"impact":"Limited","healthObjective":99.95,"dependencies":{"aggregationType":"MaxNotHealthy","ignoreUnknown":true,"unit":"Percentage","unhealthyThreshold":75.5,"degradedThreshold":25},"signals":[{"name":"signal-a0465975c3cd65f2","displayName":"api_check","signalKind":"PrometheusMetricsQuery","signalDefinitionName":"signal-a0465975c3cd65f2"},{"name":"signal-334b02c455746707","displayName":"resource-state","signalKind":"PrometheusMetricsQuery","signalDefinitionName":"signal-334b02c455746707"}]},{"name":"sample-health","displayName":"AppHost","canvasPosition":{"x":0,"y":0},"impact":"Standard","healthObjective":null,"dependencies":{"aggregationType":"WorstOf","ignoreUnknown":true},"signals":[]}]') +var signalConfigurations = json('[{"name":"signal-a0465975c3cd65f2","displayName":"API\\u0027s \${literal} label: api_check","queryText":"min(((aspire_health_status{resource_name=\\u0022api\\u0022,health_check=\\u0022api_check\\u0022,replica_index=\\u00221\\u0022} \\u003E= 0) \\u003C= 2) and (time() - timestamp(aspire_health_status{resource_name=\\u0022api\\u0022,health_check=\\u0022api_check\\u0022,replica_index=\\u00221\\u0022}) \\u003C 180))"},{"name":"signal-334b02c455746707","displayName":"API\\u0027s \${literal} label: resource-state","queryText":"min(((aspire_health_status{resource_name=\\u0022api\\u0022,health_check=\\u0022resource-state\\u0022,replica_index=\\u00221\\u0022} \\u003E= 0) \\u003C= 2) and (time() - timestamp(aspire_health_status{resource_name=\\u0022api\\u0022,health_check=\\u0022resource-state\\u0022,replica_index=\\u00221\\u0022}) \\u003C 180))"}]') +var relationshipDefinitions = json('[{"name":"relationship-a209a7c32ab60a44","parentEntityName":"sample-health","childEntityName":"resource-api"}]') + +resource workspace 'Microsoft.Monitor/accounts@2023-04-03' = { + name: '${prefix}-amw' + location: location + properties: { + publicNetworkAccess: 'Enabled' + } +} + +resource ingestionEndpoint 'Microsoft.Insights/dataCollectionEndpoints@2023-03-11' = { + name: '${prefix}-dce' + location: location + kind: 'Linux' + properties: { + networkAcls: { + publicNetworkAccess: 'Enabled' + } + } +} + +resource ingestionRule 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { + name: '${prefix}-dcr' + location: location + kind: 'Linux' + properties: { + dataCollectionEndpointId: ingestionEndpoint.id + dataSources: { + prometheusForwarder: [ + { + name: 'health-metrics' + streams: [ + 'Microsoft-PrometheusMetrics' + ] + } + ] + } + destinations: { + monitoringAccounts: [ + { + name: 'health-workspace' + accountResourceId: workspace.id + } + ] + } + dataFlows: [ + { + streams: [ + 'Microsoft-PrometheusMetrics' + ] + destinations: [ + 'health-workspace' + ] + } + ] + } +} + +resource collectorIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${prefix}-collector' + location: location +} + +resource metricsPublisher 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(ingestionRule.id, collectorIdentity.id, '3913510d-42f4-4e42-8a64-420c390055eb') + scope: ingestionRule + properties: { + principalId: collectorIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') + } +} + +resource healthModel 'Microsoft.CloudHealth/healthmodels@2026-09-01-preview' = { + name: healthModelName + location: location + identity: { + type: 'SystemAssigned' + } + properties: {} +} + +resource modelReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(workspace.id, healthModel.id, '43d0d8ad-25c7-4714-9337-8ba259a9fe05') + scope: workspace + properties: { + principalId: healthModel.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '43d0d8ad-25c7-4714-9337-8ba259a9fe05') + } +} + +resource authentication 'Microsoft.CloudHealth/healthmodels/authenticationsettings@2026-09-01-preview' = { + parent: healthModel + name: 'workspace-reader' + properties: { + authenticationKind: 'ManagedIdentity' + managedIdentityName: 'SystemAssigned' + } +} + +resource signalDefinitions 'Microsoft.CloudHealth/healthmodels/signaldefinitions@2026-09-01-preview' = [for signal in signalConfigurations: { + parent: healthModel + name: signal.name + properties: { + displayName: signal.displayName + signalKind: 'PrometheusMetricsQuery' + queryText: signal.queryText + refreshInterval: 'PT1M' + timeGrain: 'PT1M' + evaluationRules: { + degradedRule: { + operator: 'LessThan' + threshold: 2 + } + unhealthyRule: { + operator: 'LessThan' + threshold: 1 + } + } + } +}] + +resource modelEntities 'Microsoft.CloudHealth/healthmodels/entities@2026-09-01-preview' = [for entity in entityDefinitions: { + parent: healthModel + name: entity.name == definitionRootName ? healthModel.name : entity.name + properties: union({ + displayName: entity.displayName + canvasPosition: entity.canvasPosition + impact: entity.impact + signalGroups: union({ + dependencies: entity.dependencies + }, empty(entity.signals) ? {} : { + azureMonitorWorkspace: { + authenticationSetting: authentication.name + azureMonitorWorkspaceResourceId: workspace.id + signals: entity.signals + } + }) + }, entity.healthObjective == null ? {} : { + healthObjective: entity.healthObjective + }) + dependsOn: [ + modelReader + signalDefinitions + ] +}] + +resource modelRelationships 'Microsoft.CloudHealth/healthmodels/relationships@2026-09-01-preview' = [for relationship in relationshipDefinitions: { + parent: healthModel + name: relationship.name + properties: { + parentEntityName: relationship.parentEntityName == definitionRootName ? healthModel.name : relationship.parentEntityName + childEntityName: relationship.childEntityName + } + dependsOn: [ + modelEntities + ] +}] + +output healthModelId string = healthModel.id +output workspaceId string = workspace.id +output dataCollectionEndpointId string = ingestionEndpoint.id +output dataCollectionRuleId string = ingestionRule.id +output remoteWriteEndpoint string = '${ingestionEndpoint.properties.metricsIngestion.endpoint}/dataCollectionRules/${ingestionRule.properties.immutableId}/streams/Microsoft-PrometheusMetrics/api/v1/write?api-version=2023-04-24' +output collectorIdentityId string = collectorIdentity.id +output collectorClientId string = collectorIdentity.properties.clientId \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.json new file mode 100644 index 00000000000..826818936d5 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureHealthModelTests.PublishPreservesTheModelAndCreatesMonitoringResources.verified.json @@ -0,0 +1 @@ +{"type":"azure.bicep.v0","path":"health.module.bicep"} \ No newline at end of file diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj index 22f30565e2f..cece68aec33 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/HealthModelExportsTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/HealthModelExportsTests.cs new file mode 100644 index 00000000000..57b8b70d396 --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/HealthModelExportsTests.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREAZUREHEALTH001 + +using Aspire.Hosting.Azure; +using Aspire.Hosting.RemoteHost; + +namespace Aspire.Hosting.CodeGeneration.TypeScript.Tests; + +public class HealthModelExportsTests +{ + [Fact] + public async Task PublishingApisGenerateTypedResourceAndEndpointSignatures() + { + var result = AtsCapabilityScanner.ScanAssemblies( + [typeof(DistributedApplication).Assembly, typeof(AzureHealthModelResource).Assembly]); + var exports = result.Capabilities + .Where(capability => capability.CapabilityId.StartsWith("Aspire.Hosting.Azure.HealthModels/", StringComparison.Ordinal)) + .OrderBy(capability => capability.MethodName).ToArray(); + + Assert.Collection(exports, + capability => Assert.Equal("addAzureContainerAppsHealthModelCollector", capability.MethodName), + capability => Assert.Equal("addAzureHealthModel", capability.MethodName)); + + var files = new AtsTypeScriptCodeGenerator().GenerateDistributedApplication(result.ToAtsContext()); + var signatures = files["aspire.mts"].Split('\n').Where(line => + line.Contains("addAzureHealthModel(", StringComparison.Ordinal) || + line.Contains("addAzureContainerAppsHealthModelCollector(", StringComparison.Ordinal)); + + await Verify(string.Join('\n', signatures), "ts"); + } +} diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HealthModelExportsTests.PublishingApisGenerateTypedResourceAndEndpointSignatures.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HealthModelExportsTests.PublishingApisGenerateTypedResourceAndEndpointSignatures.verified.ts new file mode 100644 index 00000000000..71be13ee624 --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HealthModelExportsTests.PublishingApisGenerateTypedResourceAndEndpointSignatures.verified.ts @@ -0,0 +1,6 @@ + addAzureContainerAppsHealthModelCollector(name: string, healthModel: Awaitable, metricsEndpoint: Awaitable): ContainerResourcePromise; + addAzureHealthModel(name: string, definitionFile: string): AzureHealthModelResourcePromise; + addAzureContainerAppsHealthModelCollector(name: string, healthModel: Awaitable, metricsEndpoint: Awaitable): ContainerResourcePromise; + addAzureHealthModel(name: string, definitionFile: string): AzureHealthModelResourcePromise; + addAzureContainerAppsHealthModelCollector(name: string, healthModel: Awaitable, metricsEndpoint: Awaitable): ContainerResourcePromise { + addAzureHealthModel(name: string, definitionFile: string): AzureHealthModelResourcePromise { diff --git a/tests/Shared/DashboardModel/ModelTestHelpers.cs b/tests/Shared/DashboardModel/ModelTestHelpers.cs index 3fdeaee8596..88667bd8015 100644 --- a/tests/Shared/DashboardModel/ModelTestHelpers.cs +++ b/tests/Shared/DashboardModel/ModelTestHelpers.cs @@ -27,7 +27,8 @@ public static ResourceViewModel CreateResource( string? iconName = null, IconVariant? iconVariant = null, ImmutableArray? volumes = null, - ImmutableArray? healthReports = null) + ImmutableArray? healthReports = null, + int replicaIndex = 0) { return new ResourceViewModel { @@ -35,7 +36,7 @@ public static ResourceViewModel CreateResource( ResourceType = resourceType ?? KnownResourceTypes.Container, DisplayName = displayName ?? resourceName ?? "Display name!", Uid = Guid.NewGuid().ToString(), - ReplicaIndex = 0, + ReplicaIndex = replicaIndex, CreationTimeStamp = DateTime.UtcNow, StartTimeStamp = DateTime.UtcNow, StopTimeStamp = DateTime.UtcNow, From 369e80bd555edda77092349571775d1a3a3769dd Mon Sep 17 00:00:00 2001 From: James Gould Date: Tue, 15 Sep 2026 19:28:13 +0100 Subject: [PATCH 27/28] fixed merge issues --- .../Controls/HealthModelEntityDetails.razor | 54 ++++++++-------- .../Controls/HealthModelGraph.razor | 6 +- .../Components/Pages/HealthModel.razor | 16 +++-- .../Components/Pages/Resources.razor | 10 +-- .../Components/Pages/Resources.razor.cs | 61 ++++++------------- .../wwwroot/js/app-resourcegraph.js | 15 +++-- .../Pages/ResourcesTests.cs | 27 +++++++- 7 files changed, 91 insertions(+), 98 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor index 5b66087706e..ce0df43d053 100644 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor @@ -8,48 +8,46 @@ {

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelDesignerTab)]

-
- - + +
- - @foreach (var impact in Enum.GetValues()) - { - @HealthModelLabels.Impact(impact, Loc) - } - +

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelImpactHint)]

@if (Node.Children.Length > 0) { - - @foreach (var aggregation in Enum.GetValues()) - { - @HealthModelLabels.Aggregation(aggregation, Loc) - } - + @if (_aggregation != nameof(DependenciesAggregationType.WorstOf)) { - - @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelAbsolute)] - @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPercentage)] - - - + + +

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelThresholdHint)]

} } -

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelObjectiveHint)]

@if (_validationError) { } - @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelApply)] + @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelApply)]
} @@ -144,7 +142,7 @@
- @Node.Entity.Signals.Length + @Node.Entity.Signals.Length
@if (Node.Entity.Signals.Length == 0) { @@ -156,7 +154,7 @@ @@ -183,7 +181,7 @@
- @Node.Children.Length + @Node.Children.Length
@if (Node.Children.Length == 0) { @@ -194,7 +192,7 @@ diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor index 4ac8aed9807..b16175abf56 100644 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor +++ b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor @@ -52,13 +52,13 @@
- - -
diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor index 3573e5ea9cf..a08e1a6b5a1 100644 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor +++ b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor @@ -19,7 +19,7 @@
@_applicationName - @Loc[nameof(Strings.HealthModelLocalPreview)] + @Loc[nameof(Strings.HealthModelLocalPreview)] @Loc[_dirty ? nameof(Strings.HealthModelUnsaved) : _savedInBrowser ? nameof(Strings.HealthModelSaved) : nameof(Strings.HealthModelDefaultLayout)]
@@ -35,7 +35,7 @@
- @@ -45,7 +45,7 @@ @if (_view == HealthModelView.Designer) {
- + @Loc[nameof(Strings.HealthModelSave)] @Loc[nameof(Strings.HealthModelDiscard)] @@ -70,9 +70,13 @@ }
- + Placeholder="@ControlsStringsLoc[nameof(ControlsStrings.FilterPlaceholder)]"> + + + +

@Loc[IsDesigner ? nameof(Strings.HealthModelDesignerHint) : nameof(Strings.HealthModelGraphHint)]

@if (_message is not null) @@ -94,7 +98,7 @@
diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index 04a93840ff1..1a9c833742e 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -263,15 +263,7 @@ }
- - @* FluentMenu unconditionally stamps aria-expanded on its Anchor element (see - FluentMenu.razor.js). This is a cursor-positioned context menu (ShowContextMenuAsync - opens it at the click coordinates), so the anchor is only an id reference, never a - visible trigger. Point it at a hidden, non-focusable element kept out of the - accessibility tree so the stamped aria-expanded doesn't land on the role-less visible - summary
(axe aria-allowed-attr) and AT users don't meet a non-operable control. *@ - - +
("focusResourceMenuItem", _graphInstanceId, itemId, ContextMenuAnchorId); - if (!focused && !_disposed && _contextMenuOpen) + if (!focused && !_isDisposing && _contextMenuOpen) { Logger.LogWarning("Unable to focus the resource context menu item '{MenuItemId}'.", itemId); } } - if (!_disposed && PageViewModel.SelectedViewKind == ResourceViewKind.Graph && !_graphInitialized) + if (!_isDisposing && PageViewModel.SelectedViewKind == ResourceViewKind.Graph && !_graphInitialized) { // Before any awaits, set a flag to indicate the graph is initialized. This prevents the graph being initialized multiple times. _graphInitialized = true; _jsModule = await JS.InvokeAsync("import", "/js/app-resourcegraph.js"); - if (_disposed) + if (_isDisposing) { await JSInteropHelpers.SafeDisposeAsync(_jsModule); _jsModule = null; @@ -428,7 +427,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private async Task UpdateResourceGraphResourcesAsync() { - if (_disposed || PageViewModel.SelectedViewKind != ResourceViewKind.Graph || _jsModule is null) + if (_isDisposing || PageViewModel.SelectedViewKind != ResourceViewKind.Graph || _jsModule is null) { return; } @@ -681,11 +680,6 @@ private async Task ShowContextMenuAsync(ResourceViewModel resource, int clientX, showConsoleLogsItem: true, showUrls: true); - // The previous context menu should always be closed by this point but complete just in case. - _contextMenuClosedTcs?.TrySetResult(); - - _contextMenuClosedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (focusElementId is not null) { // Cursor-positioned menus use a hidden anchor, so Fluent cannot transfer keyboard @@ -694,7 +688,7 @@ private async Task ShowContextMenuAsync(ResourceViewModel resource, int clientX, .FirstOrDefault(item => !item.IsHeader && !item.IsDivider && !item.IsDisabled)?.Id; } - await contextMenu.OpenAsync(screenWidth, screenHeight, clientX, clientY); + await contextMenu.OpenAsync(clientX, clientY); StateHasChanged(); } @@ -947,7 +941,7 @@ internal static ResourceViewKind GetVisibleViewKindForViewChange(ResourceViewKin private async Task UpdateResourceGraphSelectedAsync() { - if (!_disposed && _jsModule is not null) + if (!_isDisposing && _jsModule is not null) { await _jsModule.InvokeVoidAsync("updateResourcesGraphSelected", PageViewModel.SelectedResource?.Name); } @@ -1042,7 +1036,7 @@ public ResourcesPageState ConvertViewModelToSerializable() public async ValueTask DisposeAsync() { _isDisposing = true; - CompleteContextMenuClosed(); + _pendingContextMenuFocusItemId = null; _cts.Cancel(); _logsSubscription?.Dispose(); @@ -1078,38 +1072,19 @@ private async Task ContextMenuOpenChangedAsync(bool open) await _jsModule.InvokeVoidAsync("updateResourcesGraphContextMenu", open); } - await CloseContextMenuAsync(closeMenu: false); - } - - private async Task CloseContextMenuAsync(bool closeMenu) - { - _contextMenuOpen = false; - _pendingContextMenuFocusItemId = null; - var focusElementId = _contextMenuFocusElementId; - _contextMenuFocusElementId = null; - - if (_contextMenu is { } menu) + if (!open) { - if (closeMenu) + _pendingContextMenuFocusItemId = null; + var focusElementId = _contextMenuFocusElementId; + _contextMenuFocusElementId = null; + + // Restore a keyboard-triggered menu to the graph cog before a selected menu item's + // callback can move focus to its destination (for example, the resource details view). + if (!string.IsNullOrEmpty(focusElementId)) { - await menu.CloseAsync(); + await JS.InvokeVoidAsync("focusElement", focusElementId); } } - - // Restore a keyboard-triggered menu to the graph cog before a selected menu item's - // callback can move focus to its destination (for example, the resource details view). - if (!string.IsNullOrEmpty(focusElementId)) - { - await JS.InvokeVoidAsync("focusElement", focusElementId); - } - - CompleteContextMenuClosed(); - } - - private void CompleteContextMenuClosed() - { - _contextMenuClosedTcs?.TrySetResult(); - _contextMenuClosedTcs = null; } // IComponentWithTelemetry impl diff --git a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js index f109b19650f..978f1204ebc 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js @@ -34,6 +34,10 @@ export function updateResourcesGraphSelected(resourceName) { } } +export function updateResourcesGraphContextMenu(open) { + resourceGraph?.contextMenuChanged(open); +} + export function focusResourceMenuItem(instanceId, itemId, anchorId) { return resourceGraph?.instanceId === instanceId ? resourceGraph.focusMenuItem(itemId, anchorId) @@ -1005,20 +1009,19 @@ class ResourceGraph { this.contextMenuChanged(true); try { - // Wait for method completion. It completes when the context menu is closed. - await this.resourcesInterop.invokeMethodAsync('ResourceContextMenu', id, window.innerWidth, window.innerHeight, clientX, clientY, focusElementId); + // Opening completes immediately; subsequent menu events report the open state separately. + await this.resourcesInterop.invokeMethodAsync('ResourceContextMenu', id, clientX, clientY, focusElementId); } catch (error) { this.contextMenuChanged(false); throw error; - } finally { - this.cancelMenuFocus?.(); - this.openContextMenu = false; - trigger?.setAttribute("aria-expanded", "false"); + } + }; contextMenuChanged = (open) => { this.openContextMenu = open; this.contextMenuTrigger?.setAttribute("aria-expanded", open ? "true" : "false"); if (!open) { + this.cancelMenuFocus?.(); this.contextMenuTrigger = null; this.updateNodeHighlights(null); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index e0c1372fdb8..485fb090824 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -382,8 +382,10 @@ public void ResourceGraph_MultipleRenders_InitializeOnce() Assert.Equal(true, focusInvocation.Arguments[1]); } - [Fact] - public async Task ResourceGraphContextMenu_OpensWithoutWaitingForClose() + [Theory] + [InlineData(null)] + [InlineData("resource-menu-trigger")] + public async Task ResourceGraphContextMenu_OpensWithoutWaitingForClose(string? focusElementId) { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); var resource = CreateResource( @@ -401,6 +403,10 @@ public async Task ResourceGraphContextMenu_OpensWithoutWaitingForClose() resourceGraphModule.SetupVoid("initializeResourcesGraph", _ => true); resourceGraphModule.SetupVoid("updateResourcesGraph", _ => true); resourceGraphModule.SetupVoid("selectResource", _ => true); + var menuStateHandler = resourceGraphModule.SetupVoid("updateResourcesGraphContextMenu", _ => true); + menuStateHandler.SetVoidResult(); + var focusMenuItemHandler = resourceGraphModule.Setup("focusResourceMenuItem", _ => true); + focusMenuItemHandler.SetResult(true); resourceGraphModule.SetupVoid("disposeResourcesGraph", _ => true).SetVoidResult(); var navigationManager = Services.GetRequiredService(); @@ -414,7 +420,7 @@ public async Task ResourceGraphContextMenu_OpensWithoutWaitingForClose() var showContextMenuAsync = typeof(Components.Pages.Resources) .GetMethod("ShowContextMenuAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; - await cut.InvokeAsync(() => (Task)showContextMenuAsync.Invoke(cut.Instance, [resource, 20, 20, null])!); + await cut.InvokeAsync(() => (Task)showContextMenuAsync.Invoke(cut.Instance, [resource, 20, 20, focusElementId])!); cut.WaitForAssertion(() => Assert.True(cut.FindComponents().Single(m => !m.Instance.Anchored).Instance.Open)); var contextMenu = cut.FindComponents().Single(m => !m.Instance.Anchored); @@ -423,12 +429,27 @@ public async Task ResourceGraphContextMenu_OpensWithoutWaitingForClose() Assert.True(headerItem.IsHeader); Assert.Equal("Resource1", headerItem.Text); Assert.NotNull(headerItem.Icon); + Assert.Collection(JSInterop.Invocations.Where(i => i.Identifier == "focusElement"), + invocation => Assert.Equal("resourcesGraphContainer", invocation.Arguments[0])); + if (focusElementId is not null) + { + cut.WaitForAssertion(() => + { + var invocation = Assert.Single(focusMenuItemHandler.Invocations); + Assert.Equal(contextMenu.Instance.Anchor, invocation.Arguments[2]); + Assert.Equal(contextMenu.Instance.Items.First(item => !item.IsHeader && !item.IsDivider && !item.IsDisabled).Id, invocation.Arguments[1]); + }); + } await cut.InvokeAsync(() => contextMenu.FindComponent().Instance.OnOpenedChangedAsync(false)); Assert.False(contextMenu.Instance.Open); Assert.Equal(false, menuStateHandler.Invocations.Last().Arguments[0]); Assert.Empty(cut.FindComponents()); + if (focusElementId is not null) + { + Assert.Single(JSInterop.Invocations, i => i.Identifier == "focusElement" && Equals(i.Arguments[0], focusElementId)); + } } [Fact] From 902e40ff76bff78b162745366c1b1e6570a2dca4 Mon Sep 17 00:00:00 2001 From: James Gould Date: Tue, 15 Sep 2026 19:47:38 +0100 Subject: [PATCH 28/28] fixed broken colours and removed old artefact hm --- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 17 - .../Controls/HealthModelEntityDetails.razor | 228 --------- .../HealthModelEntityDetails.razor.cs | 130 ----- .../HealthModelEntityDetails.razor.css | 46 -- .../Controls/HealthModelGraph.razor | 65 --- .../Controls/HealthModelGraph.razor.cs | 153 ------ .../Controls/HealthModelGraph.razor.css | 53 -- .../Components/Layout/DesktopNavMenu.razor | 8 - .../Components/Layout/DesktopNavMenu.razor.cs | 4 - .../Components/Layout/MobileNavMenu.razor.cs | 11 - .../Components/Pages/HealthModel.razor | 141 ------ .../Components/Pages/HealthModel.razor.cs | 452 ----------------- .../Components/Pages/HealthModel.razor.css | 43 -- .../Components/Pages/Resources.razor.css | 6 +- .../HealthModel/AspireHealthModelBuilder.cs | 125 +---- .../Model/HealthModel/HealthModelDocument.cs | 102 ---- .../Model/HealthModel/HealthModelEntity.cs | 105 ---- .../Model/HealthModel/HealthModelEvaluator.cs | 135 ------ .../Model/HealthModel/HealthModelLabels.cs | 32 -- .../Model/HealthModel/HealthModelLayout.cs | 88 ---- .../Model/HealthModel/HealthModelSignal.cs | 128 ----- .../Model/HealthModel/HealthModelSnapshot.cs | 71 --- .../Model/HealthModel/HealthModelTopology.cs | 55 --- .../HealthModel/HealthStateExtensions.cs | 27 -- .../Resources/HealthModel.Designer.cs | 425 ---------------- .../Resources/HealthModel.resx | 266 ---------- .../Resources/Layout.Designer.cs | 9 - src/Aspire.Dashboard/Resources/Layout.resx | 3 - .../Resources/xlf/HealthModel.cs.xlf | 457 ------------------ .../Resources/xlf/HealthModel.de.xlf | 457 ------------------ .../Resources/xlf/HealthModel.es.xlf | 457 ------------------ .../Resources/xlf/HealthModel.fr.xlf | 457 ------------------ .../Resources/xlf/HealthModel.it.xlf | 457 ------------------ .../Resources/xlf/HealthModel.ja.xlf | 457 ------------------ .../Resources/xlf/HealthModel.ko.xlf | 457 ------------------ .../Resources/xlf/HealthModel.pl.xlf | 457 ------------------ .../Resources/xlf/HealthModel.pt-BR.xlf | 457 ------------------ .../Resources/xlf/HealthModel.ru.xlf | 457 ------------------ .../Resources/xlf/HealthModel.tr.xlf | 457 ------------------ .../Resources/xlf/HealthModel.zh-Hans.xlf | 457 ------------------ .../Resources/xlf/HealthModel.zh-Hant.xlf | 457 ------------------ .../Resources/xlf/Layout.cs.xlf | 5 - .../Resources/xlf/Layout.de.xlf | 5 - .../Resources/xlf/Layout.es.xlf | 5 - .../Resources/xlf/Layout.fr.xlf | 5 - .../Resources/xlf/Layout.it.xlf | 5 - .../Resources/xlf/Layout.ja.xlf | 5 - .../Resources/xlf/Layout.ko.xlf | 5 - .../Resources/xlf/Layout.pl.xlf | 5 - .../Resources/xlf/Layout.pt-BR.xlf | 5 - .../Resources/xlf/Layout.ru.xlf | 5 - .../Resources/xlf/Layout.tr.xlf | 5 - .../Resources/xlf/Layout.zh-Hans.xlf | 5 - .../Resources/xlf/Layout.zh-Hant.xlf | 5 - .../wwwroot/js/app-healthmodel.js | 145 ------ src/Shared/DashboardUrls.cs | 16 - .../Pages/HealthModelTests.cs | 218 --------- .../Shared/HealthModelSetupHelpers.cs | 25 - .../Playwright/HealthModelTests.cs | 184 ------- .../Model/AspireHealthModelBuilderTests.cs | 201 -------- .../Model/HealthModelDocumentTests.cs | 193 -------- .../Model/HealthModelEvaluatorTests.cs | 361 -------------- ...cumentContainsOnlyDefinition.verified.json | 91 ---- 63 files changed, 4 insertions(+), 10364 deletions(-) delete mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor delete mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs delete mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css delete mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor delete mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs delete mode 100644 src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css delete mode 100644 src/Aspire.Dashboard/Components/Pages/HealthModel.razor delete mode 100644 src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs delete mode 100644 src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs delete mode 100644 src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs delete mode 100644 src/Aspire.Dashboard/Resources/HealthModel.Designer.cs delete mode 100644 src/Aspire.Dashboard/Resources/HealthModel.resx delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf delete mode 100644 src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf delete mode 100644 src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js delete mode 100644 tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs delete mode 100644 tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs delete mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs delete mode 100644 tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs delete mode 100644 tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs delete mode 100644 tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index e21fb1dca4a..4b8348df176 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -166,11 +166,6 @@ True Reconnect.resx - - True - True - HealthModel.resx - @@ -276,13 +271,6 @@ PublicResXFileCodeGenerator Reconnect.Designer.cs - - Resx - EmbeddedResource - Designer - PublicResXFileCodeGenerator - HealthModel.Designer.cs - @@ -294,12 +282,7 @@ - - - - - diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor deleted file mode 100644 index ce0df43d053..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor +++ /dev/null @@ -1,228 +0,0 @@ -@using Aspire.Dashboard.Components.Controls.Grid -@using Aspire.Dashboard.Model.HealthModel -@using Aspire.Dashboard.Resources -@using Aspire.Dashboard.Utils - -
- @if (Editable && Configuration is not null) - { -
-

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelDesignerTab)]

- -
- - -
- -

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelImpactHint)]

- @if (Node.Children.Length > 0) - { - - @if (_aggregation != nameof(DependenciesAggregationType.WorstOf)) - { - - - - -

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelThresholdHint)]

- } - } - -

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelObjectiveHint)]

- @if (_validationError) - { - - } - @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelApply)] -
- } - - -
-
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelTypeColumnHeader)] - @Node.Entity.Category -
-
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelHealthColumnHeader)] - - @{ - var (icon, color) = HealthModelIconHelpers.GetHealthStateIcon(Node.State); - } - - @HealthModelLabels.State(Node.State, Loc) - -
-
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertySignalsState)] - @HealthModelLabels.State(Node.SignalsState, Loc) -
-
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyDependenciesState)] - - @if (Node.DependenciesState is { } dependenciesState) - { - @HealthModelLabels.State(dependenciesState, Loc) - } - else - { - - } - -
-
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyImpact)] - @HealthModelLabels.Impact(Node.Entity.Impact, Loc) -
-
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyRollup)] - @RollupDescription -
- @if (Node.Entity.Dependencies.AggregationType != DependenciesAggregationType.WorstOf) - { -
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelUnhealthyThreshold)] - @Node.Entity.Dependencies.UnhealthyThreshold?.ToString("0.##", CultureInfo.CurrentCulture) @ThresholdUnit -
- @if (Node.Entity.Dependencies.DegradedThreshold is { } degraded) - { -
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelDegradedThreshold)] - @degraded.ToString("0.##", CultureInfo.CurrentCulture) @ThresholdUnit -
- } - } - @if (Node.Entity.HealthObjective is { } objective) - { -
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelHealthObjective)] - @objective.ToString("0.##", CultureInfo.CurrentCulture) -
- } - @if (Configuration is { } config) - { -
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPositionX)] / @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPositionY)] - @config.CanvasPosition.X.ToString("0.##", CultureInfo.CurrentCulture) / @config.CanvasPosition.Y.ToString("0.##", CultureInfo.CurrentCulture) -
- } -
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelEntityId)] - @Node.Name -
-
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropagation)] - @HealthModelLabels.State(Node.State.ApplyImpact(Node.Entity.Impact), Loc) -
- @if (Node.Entity.ResourceName is { } resourceName) - { -
- @Loc[nameof(Dashboard.Resources.HealthModel.HealthModelPropertyResource)] - - @resourceName - -
- } -
-
- - -
- @Node.Entity.Signals.Length -
- @if (Node.Entity.Signals.Length == 0) - { -
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelNoSignals)]
- } - else - { -

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelSignalsSource)]

- - - @(context.DisplayName ?? context.Name) - - - @{ - var (signalIcon, signalColor) = HealthModelIconHelpers.GetHealthStateIcon(context.State); - } - - @HealthModelLabels.State(context.State, Loc) - - - @context.Description - - - } -
- - -
- @Node.Children.Length -
- @if (Node.Children.Length == 0) - { -
@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelNoDependencies)]
- } - else - { - - - - - - @{ - var (childIcon, childColor) = HealthModelIconHelpers.GetHealthStateIcon(context.State); - } - - @HealthModelLabels.State(context.State, Loc) - - - @HealthModelLabels.Impact(context.Entity.Impact, Loc) - - - } -
-
- @if (Parents.Count > 0) - { -
-

@Loc[nameof(Dashboard.Resources.HealthModel.HealthModelParents)]

- @foreach (var parent in Parents) - { - - } -
- } -
diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs deleted file mode 100644 index 40df75ff4f2..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.cs +++ /dev/null @@ -1,130 +0,0 @@ -// 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.Model.HealthModel; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Localization; -using Strings = Aspire.Dashboard.Resources.HealthModel; - -namespace Aspire.Dashboard.Components.Controls; - -public partial class HealthModelEntityDetails : ComponentBase -{ - [Inject] - public required IStringLocalizer Loc { get; init; } - [Parameter, EditorRequired] - public required HealthModelNode Node { get; set; } - [Parameter] - public HealthModelSnapshot? Snapshot { get; set; } - [Parameter] - public HealthModelEntityConfiguration? Configuration { get; set; } - [Parameter] - public bool Editable { get; set; } - [Parameter] - public EventCallback OnApply { get; set; } - [Parameter] - public EventCallback OnSelect { get; set; } - - private IQueryable _signals = Enumerable.Empty().AsQueryable(); - private IQueryable _children = Enumerable.Empty().AsQueryable(); - private HealthModelEntityConfiguration? _previous; - private string _displayName = string.Empty; - private string _impact = nameof(EntityImpact.Standard); - private string _aggregation = nameof(DependenciesAggregationType.WorstOf); - private string _unit = nameof(AggregationUnit.Absolute); - private double _x; - private double _y; - private double? _objective; - private double? _degraded; - private double? _unhealthy; - private bool _ignoreUnknown = true; - private bool _validationError; - - private string RollupDescription => HealthModelLabels.Aggregation(Node.Entity.Dependencies.AggregationType, Loc); - private string ThresholdUnit => Loc[Node.Entity.Dependencies.Unit == AggregationUnit.Percentage - ? nameof(Strings.HealthModelPercentage) : nameof(Strings.HealthModelAbsolute)]; - private IReadOnlyList Parents => Snapshot is null ? [] : - Snapshot.AllNodes.Where(n => n.Children.Any(child => child.Name == Node.Name)).ToArray(); - - protected override void OnParametersSet() - { - _signals = Node.Entity.Signals.ToList().AsQueryable(); - _children = Node.Children.ToList().AsQueryable(); - if (Configuration is not { } config) - { - return; - } - // Health refreshes must not overwrite a partially edited form. Only reload when the selected - // entity or its declarative settings actually change (for example, Discard or Undo). - if (_previous is { } previous && - (previous.Name, previous.DisplayName, previous.CanvasPosition, previous.Impact, previous.Dependencies, previous.HealthObjective) == - (config.Name, config.DisplayName, config.CanvasPosition, config.Impact, config.Dependencies, config.HealthObjective)) - { - return; - } - _previous = config; - _displayName = config.DisplayName; - _x = config.CanvasPosition.X; - _y = config.CanvasPosition.Y; - _impact = config.Impact.ToString(); - _aggregation = config.Dependencies.AggregationType.ToString(); - _unit = config.Dependencies.Unit.ToString(); - _degraded = config.Dependencies.DegradedThreshold; - _unhealthy = config.Dependencies.UnhealthyThreshold; - _ignoreUnknown = config.Dependencies.IgnoreUnknown; - _objective = config.HealthObjective; - _validationError = false; - } - - private async Task ApplyAsync() - { - if (!Editable || Configuration is null) - { - return; - } - _validationError = false; - if (!Enum.TryParse(_impact, out var impact) || - !Enum.TryParse(_aggregation, out var aggregation) || - !Enum.TryParse(_unit, out var unit)) - { - _validationError = true; - return; - } - - var dependencies = aggregation == DependenciesAggregationType.WorstOf - ? DependenciesAggregation.WorstOf - : new DependenciesAggregation - { - AggregationType = aggregation, - Unit = unit, - DegradedThreshold = _degraded, - UnhealthyThreshold = _unhealthy, - IgnoreUnknown = _ignoreUnknown - }; - try - { - HealthModelDocuments.ValidateAggregation(dependencies); - } - catch (InvalidDataException) - { - _validationError = true; - return; - } - if (string.IsNullOrWhiteSpace(_displayName) || _displayName.Length > 260 || - !HealthModelDocuments.IsValidPosition(new HealthModelCanvasPosition(_x, _y)) || - _objective is { } objective && (!double.IsFinite(objective) || objective < 0 || objective > 100)) - { - _validationError = true; - return; - } - - await OnApply.InvokeAsync(Configuration with - { - DisplayName = _displayName.Trim(), - CanvasPosition = new HealthModelCanvasPosition(_x, _y), - Impact = Node.Entity.ResourceName is null ? EntityImpact.Standard : impact, - Dependencies = dependencies, - HealthObjective = _objective - }); - } -} diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css b/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css deleted file mode 100644 index 5a5d5f1f758..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelEntityDetails.razor.css +++ /dev/null @@ -1,46 +0,0 @@ -.health-model-details-layout { - height: 100%; - overflow: auto; -} - -.health-model-entity-editor { padding: 12px; display: flex; flex-direction: column; gap: 12px; border-bottom: 1px solid var(--neutral-stroke-divider-rest); } -.health-model-entity-editor h3 { margin: 0; } -.health-model-coordinate-fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; } -.health-model-editor-hint { font-size: 12px; color: var(--foreground-subtext-rest); margin: 0; white-space: normal; } -.health-model-settings-error { color: var(--error); } -.health-model-identity { overflow-wrap: anywhere; font-size: 11px; } -.health-model-parent-list { padding: 12px; display: flex; flex-direction: column; gap: 8px; } -::deep .health-model-related-entity { background: none; border: 0; color: var(--accent-foreground-rest); font: inherit; padding: 0; text-align: left; cursor: pointer; } - -::deep .health-model-property-list { - display: flex; - flex-direction: column; - gap: 4px; - padding: 4px 0; -} - -::deep .health-model-property { - display: grid; - grid-template-columns: 1fr 1.5fr; - gap: 8px; - align-items: center; -} - -::deep .health-model-property-name { - color: var(--neutral-foreground-hint); -} - -::deep .health-model-property-value { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -::deep .health-model-empty { - color: var(--neutral-foreground-hint); - padding: 8px 0; -} diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor deleted file mode 100644 index b16175abf56..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor +++ /dev/null @@ -1,65 +0,0 @@ -@using Aspire.Dashboard.Model.HealthModel -@using Strings = Aspire.Dashboard.Resources.HealthModel - -
- - - - - - - - - - - - - @foreach (var relationship in Snapshot.Definition.Relationships) - { - var parent = _nodes[relationship.ParentEntityName]; - var child = _nodes[relationship.ChildEntityName]; - var state = child.State.ApplyImpact(child.Entity.Impact); - - @Loc[nameof(Strings.HealthModelRelationshipLabel), parent.DisplayName, child.DisplayName, HealthModelLabels.State(state, Loc)] - - } - - - @foreach (var node in Snapshot.AllNodes) - { - var position = _configuration[node.Name].CanvasPosition; - - @node.DisplayName - - - - @HealthModelLabels.State(node.State, Loc) - @Truncate(node.DisplayName) - @Truncate(node.Entity.Category ?? string.Empty) - @Loc[nameof(Strings.HealthModelSignalsHeader)]: @node.Entity.Signals.Length - @if (Editable) - { - - } - - } - - - -
- - - -
-
diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs deleted file mode 100644 index a7191622524..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.cs +++ /dev/null @@ -1,153 +0,0 @@ -// 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.Model.HealthModel; -using Aspire.Dashboard.Utils; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Localization; -using Microsoft.JSInterop; -using Strings = Aspire.Dashboard.Resources.HealthModel; - -namespace Aspire.Dashboard.Components.Controls; - -public sealed record HealthModelPositionChange(string Name, double X, double Y); - -public partial class HealthModelGraph : ComponentBase, IAsyncDisposable -{ - private readonly string _gridId = $"health-model-grid-{Guid.NewGuid():N}"; - private readonly string _arrowId = $"health-model-arrow-{Guid.NewGuid():N}"; - private ElementReference _svg; - private IJSObjectReference? _module; - private IJSObjectReference? _graph; - private DotNetObjectReference? _reference; - private bool _disposed; - private int _lastLayoutVersion = -1; - private Dictionary _nodes = new(StringComparer.Ordinal); - private Dictionary _configuration = new(StringComparer.Ordinal); - - [Inject] - public required IJSRuntime JS { get; init; } - [Inject] - public required IStringLocalizer Loc { get; init; } - [Parameter, EditorRequired] - public required HealthModelSnapshot Snapshot { get; set; } - [Parameter, EditorRequired] - public required HealthModelDocument Document { get; set; } - [Parameter] - public bool Editable { get; set; } - [Parameter] - public string? SelectedEntityName { get; set; } - [Parameter] - public string Filter { get; set; } = string.Empty; - [Parameter] - public HealthState? StateFilter { get; set; } - [Parameter] - public int LayoutVersion { get; set; } - [Parameter] - public EventCallback OnSelect { get; set; } - [Parameter] - public EventCallback OnPositionChanged { get; set; } - - protected override void OnParametersSet() - { - _nodes = Snapshot.AllNodes.ToDictionary(n => n.Name, StringComparer.Ordinal); - _configuration = Document.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (_disposed) - { - return; - } - if (firstRender) - { - _module = await JS.InvokeAsync("import", "/js/app-healthmodel.js"); - if (_disposed) - { - await JSInteropHelpers.SafeDisposeAsync(_module); - return; - } - _reference = DotNetObjectReference.Create(this); - _graph = await _module.InvokeAsync("createHealthModelGraph", _svg, _reference); - } - if (_graph is not null) - { - await _graph.InvokeVoidAsync("update", - Document.Entities.Select(e => new { e.Name, e.CanvasPosition.X, e.CanvasPosition.Y }).ToArray(), - Editable); - if (_lastLayoutVersion != LayoutVersion) - { - _lastLayoutVersion = LayoutVersion; - await _graph.InvokeVoidAsync("fit"); - } - } - } - - [JSInvokable] - public Task MoveEntity(string name, double x, double y) => - !_disposed && Editable && _configuration.ContainsKey(name) - ? OnPositionChanged.InvokeAsync(new HealthModelPositionChange(name, x, y)) - : Task.CompletedTask; - - [JSInvokable] - public Task SelectEntity(string name) => - !_disposed && _configuration.ContainsKey(name) ? OnSelect.InvokeAsync(name) : Task.CompletedTask; - - public async Task FitAsync() - { - if (_graph is not null) - { - await _graph.InvokeVoidAsync("fit"); - } - } - - private async Task ZoomAsync(double factor) - { - if (_graph is not null) - { - await _graph.InvokeVoidAsync("zoomBy", factor); - } - } - - private string GetNodeClass(HealthModelNode node) - { - var matches = (StateFilter is null || node.State == StateFilter) && - (Filter.Length == 0 || node.DisplayName.Contains(Filter, StringComparisons.UserTextSearch)); - return $"health-model-entity{(SelectedEntityName == node.Name ? " is-selected" : "")}{(matches ? "" : " is-dimmed")}"; - } - - private static string Truncate(string value) => value.Length > 27 ? value[..24] + "..." : value; - private static string GetTransform(HealthModelCanvasPosition position) => - FormattableString.Invariant($"translate({position.X},{position.Y})"); - - private string GetPath(HealthModelRelationship relationship) - { - var parent = _configuration[relationship.ParentEntityName].CanvasPosition; - var child = _configuration[relationship.ChildEntityName].CanvasPosition; - var y1 = parent.Y + HealthModelLayout.CardHeight / 2; - var y2 = child.Y - HealthModelLayout.CardHeight / 2; - var middle = (y1 + y2) / 2; - return string.Create(CultureInfo.InvariantCulture, $"M {parent.X} {y1} C {parent.X} {middle}, {child.X} {middle}, {child.X} {y2}"); - } - - public async ValueTask DisposeAsync() - { - _disposed = true; - if (_graph is not null) - { - try - { - await _graph.InvokeVoidAsync("dispose"); - } - catch (JSDisconnectedException) - { - // The browser already discarded the graph when the circuit disconnected. - } - await JSInteropHelpers.SafeDisposeAsync(_graph); - } - _reference?.Dispose(); - await JSInteropHelpers.SafeDisposeAsync(_module); - } -} diff --git a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css b/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css deleted file mode 100644 index ce94d50a7b3..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/HealthModelGraph.razor.css +++ /dev/null @@ -1,53 +0,0 @@ -.health-model-canvas { - position: relative; - width: 100%; - height: 100%; - min-height: 360px; - overflow: hidden; - background: var(--fill-color); -} - -.health-model-graph { - display: block; - width: 100%; - height: 100%; - min-height: 360px; - touch-action: none; -} - -.health-model-grid-dot { fill: var(--neutral-stroke-rest); opacity: 0.5; } -.health-model-edge, .health-model-entity { --health-colour: var(--info); } -[data-health="Healthy"] { --health-colour: var(--aspire-status-success); } -[data-health="Degraded"] { --health-colour: var(--aspire-status-warning); } -[data-health="Unhealthy"] { --health-colour: var(--aspire-status-error); } - -.health-model-edge { - stroke: var(--health-colour); - fill: none; - stroke-width: 2; -} - -.health-model-card { - stroke: var(--health-colour); - stroke-width: 2; - fill: color-mix(in srgb, var(--health-colour) 30%, var(--fill-color)); -} - -.health-model-selection { - fill: none; - stroke: transparent; - stroke-width: 2; -} - -.health-model-entity { cursor: pointer; outline: none; } -[data-editable="true"] .health-model-entity { cursor: grab; } -.health-model-entity.is-selected .health-model-selection, -.health-model-entity:focus-visible .health-model-selection { stroke: var(--focus-stroke-outer); } -.health-model-entity.is-dimmed { opacity: 0.35; } -.health-model-state-dot { fill: var(--health-colour); } -.health-model-connector { fill: var(--fill-color); stroke: var(--health-colour); stroke-width: 2; } -.health-model-card-name { font-size: 14px; font-weight: 600; fill: var(--neutral-foreground-rest); } -.health-model-card-state { font-size: 12px; fill: var(--neutral-foreground-rest); } -.health-model-card-type, .health-model-card-signals { font-size: 11px; fill: var(--neutral-foreground-rest); } -.health-model-entity text { pointer-events: none; user-select: none; } -.health-model-canvas-actions { position: absolute; bottom: 12px; right: 12px; display: flex; background: var(--fill-color); border-radius: 4px; } diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor index 5011947fe1b..7ef2b7a5119 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor +++ b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor @@ -30,12 +30,4 @@ IconRest="MetricsIcon()" IconActive="MetricsIcon(active: true)" Text="@Loc[nameof(Layout.NavMenuMetricsTab)]" /> - @if (DashboardClient.IsEnabled) - { - - } diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs index 7d19511a18b..e5cb85a3c19 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor.cs @@ -31,10 +31,6 @@ internal static Icon MetricsIcon(bool active = false) => active ? new Icons.Filled.Size24.ChartMultiple() : new Icons.Regular.Size24.ChartMultiple(); - internal static Icon HealthModelIcon(bool active = false) => - active ? new Icons.Filled.Size24.Heart() - : new Icons.Regular.Size24.Heart(); - [Inject] public required NavigationManager NavigationManager { get; init; } diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs index c68216d3ae1..da015d7d66f 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs @@ -181,17 +181,6 @@ private IEnumerable GetMobileNavMenuEntries() LinkMatchRegex: GetNonIndexPageRegex(DashboardUrls.MetricsUrl()) ); - if (DashboardClient.IsEnabled) - { - yield return new MobileNavMenuEntry( - Loc[nameof(Resources.Layout.NavMenuHealthModelTab)], - () => NavigateToAsync(DashboardUrls.HealthModelUrl()), - DesktopNavMenu.HealthModelIcon(), - ActiveIcon: DesktopNavMenu.HealthModelIcon(active: true), - LinkMatchRegex: GetNonIndexPageRegex(DashboardUrls.HealthModelUrl()) - ); - } - yield return new MobileNavMenuEntry( Loc[nameof(Resources.Layout.MainLayoutAspireRepoLink)], async () => diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor deleted file mode 100644 index a08e1a6b5a1..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor +++ /dev/null @@ -1,141 +0,0 @@ -@page "/healthmodel" -@using Aspire.Dashboard.Components.Controls.Grid -@using Aspire.Dashboard.Model.HealthModel -@using Aspire.Dashboard.Resources -@using Strings = Aspire.Dashboard.Resources.HealthModel - -@inject IStringLocalizer Loc -@inject IStringLocalizer ControlsStringsLoc - - - -
- - -

@Loc[nameof(Strings.HealthModelHeader)]

-
- -
-
-
- @_applicationName - @Loc[nameof(Strings.HealthModelLocalPreview)] - @Loc[_dirty ? nameof(Strings.HealthModelUnsaved) : _savedInBrowser ? nameof(Strings.HealthModelSaved) : nameof(Strings.HealthModelDefaultLayout)] -
-
- - @Loc[nameof(Strings.HealthModelExport)] - - -
-
-
- - - - - - @if (_view == HealthModelView.Designer) - { -
- - @Loc[nameof(Strings.HealthModelSave)] - - @Loc[nameof(Strings.HealthModelDiscard)] - @Loc[nameof(Strings.HealthModelUndo)] - @Loc[nameof(Strings.HealthModelArrange)] -
- } -
-
- @{ - var (icon, colour) = HealthModelIconHelpers.GetHealthStateIcon(_snapshot.State); - } - @StateLabel(_snapshot.State) - @Loc[nameof(Strings.HealthModelEntityCount), _snapshot.AllNodes.Length, _snapshot.Definition.Relationships.Length] -
- @foreach (var state in new[] { HealthState.Healthy, HealthState.Degraded, HealthState.Unhealthy, HealthState.Unknown }) - { - - } -
- - - - - -
-

@Loc[IsDesigner ? nameof(Strings.HealthModelDesignerHint) : nameof(Strings.HealthModelGraphHint)]

- @if (_message is not null) - { -
@_message
- } - @if (DashboardClient.IsReadOnly) - { -
@Loc[nameof(Strings.HealthModelReadOnly)]
- } -
- @if (_draft is not null && !_invalidTopology) - { - - - @if (_view == HealthModelView.Entities) - { -
- - - - - - @context.Entity.Category - @StateLabel(context.State) - @SignalSummary(context) - - @Loc[nameof(Strings.HealthModelNoMatch)] - -
- } - else - { - - } -
-
- -
-
- } -
-
- @Loc[nameof(Strings.HealthModelLocalPreview)] -

@Loc[nameof(Strings.HealthModelCloudBoundary)]

-

@Loc[nameof(Strings.HealthModelDefinitionHint)]

-
-
-
-
-
diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs deleted file mode 100644 index c53bee20add..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.cs +++ /dev/null @@ -1,452 +0,0 @@ -// 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.Json; -using Aspire.Dashboard.Components.Controls; -using Aspire.Dashboard.Extensions; -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Model.HealthModel; -using Aspire.Dashboard.Utils; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Forms; -using Microsoft.Extensions.Localization; -using Microsoft.FluentUI.AspNetCore.Components; -using Microsoft.JSInterop; -using Strings = Aspire.Dashboard.Resources.HealthModel; - -namespace Aspire.Dashboard.Components.Pages; - -public partial class HealthModel : ComponentBase, IAsyncDisposable -{ - private readonly CancellationTokenSource _cts = new(); - private readonly Dictionary _resources = new(StringComparers.ResourceName); - private readonly Stack _undo = new(); - private HealthModelDefinition _live = new() { Name = AspireHealthModelBuilder.RootEntityName }; - private HealthModelSnapshot _snapshot = HealthModelSnapshot.Empty; - private HealthModelDocument? _saved; - private HealthModelDocument? _draft; - private HealthModelNode? _selectedNode; - private HealthModelGraph? _graph; - private Task? _subscriptionTask; - private string _applicationName = string.Empty; - private string _filter = string.Empty; - private string? _message; - private bool _isError; - private bool _invalidTopology; - private bool _dirty; - private bool _savedInBrowser; - private bool _saving; - private bool _disposed; - private int _layoutVersion; - private HealthState? _stateFilter; - private HealthModelView _view = HealthModelView.Graph; - - [Inject] - public required DashboardDataSource DataSource { get; init; } - [Inject] - public required IDashboardClient DashboardClient { get; init; } - [Inject] - public required NavigationManager NavigationManager { get; init; } - [Inject] - public required ILocalStorage LocalStorage { get; init; } - [Inject] - public required IJSRuntime JS { get; init; } - [Inject] - public required ILogger Logger { get; init; } - - [Parameter] - [SupplyParameterFromQuery(Name = "entity")] - public string? EntityName { get; set; } - [Parameter] - [SupplyParameterFromQuery(Name = "view")] - public string? ViewName { get; set; } - - private bool CanEdit => !DashboardClient.IsReadOnly && !_invalidTopology && !_saving; - private bool IsDesigner => _view == HealthModelView.Designer && CanEdit; - private string StorageKey => $"Aspire_HealthModel_v1_{Uri.EscapeDataString(_applicationName)}"; - private IQueryable FilteredNodes => _snapshot.AllNodes - .Where(n => (_stateFilter is null || n.State == _stateFilter) && - (_filter.Length == 0 || n.DisplayName.Contains(_filter, StringComparisons.UserTextSearch))) - .ToList().AsQueryable(); - private HealthModelEntityConfiguration? SelectedConfiguration => _draft?.Entities.FirstOrDefault(e => e.Name == _selectedNode?.Name); - - protected override async Task OnInitializedAsync() - { - var cancellationToken = _cts.Token; - if (DashboardClient.IsEnabled) - { - await DashboardClient.WhenConnected.WaitAsync(cancellationToken); - } - if (_disposed) - { - return; - } - _applicationName = DashboardClient.ApplicationName; - var stored = await LocalStorage.GetAsync(StorageKey); - if (_disposed) - { - return; - } - if (stored.Success && stored.Value is { } document) - { - try - { - HealthModelDocuments.Validate(document, _applicationName); - _saved = document; - _savedInBrowser = true; - } - catch (InvalidDataException ex) - { - Logger.LogWarning(ex, "The saved health model is invalid."); - ShowMessage(nameof(Strings.HealthModelLoadError), error: true); - } - } - - var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken); - if (_disposed) - { - return; - } - foreach (var resource in snapshot) - { - _resources[resource.Name] = resource; - } - RebuildModel(); - _subscriptionTask = WatchAsync(subscription); - } - - private async Task WatchAsync(IAsyncEnumerable> subscription) - { - try - { - await foreach (var changes in subscription.WithCancellation(_cts.Token).ConfigureAwait(false)) - { - // All model and draft mutations are serialized on the renderer, including subscription - // updates that arrive while the user is dragging or saving. - await InvokeAsync(() => - { - if (_disposed) - { - return; - } - foreach (var (changeType, resource) in changes) - { - if (changeType == ResourceViewModelChangeType.Upsert) - { - _resources[resource.Name] = resource; - } - else if (changeType == ResourceViewModelChangeType.Delete) - { - _resources.Remove(resource.Name); - } - } - RebuildModel(); - StateHasChanged(); - }); - } - } - catch (OperationCanceledException) when (_cts.IsCancellationRequested) - { - } - catch (Exception ex) - { - Logger.LogError(ex, "Health model resource subscription failed."); - if (!_disposed) - { - await InvokeAsync(() => - { - ShowMessage(nameof(Strings.HealthModelSubscriptionError), error: true); - StateHasChanged(); - }); - } - } - } - - protected override void OnParametersSet() - { - if (Enum.TryParse(ViewName, ignoreCase: true, out var view) && Enum.IsDefined(view)) - { - _view = view; - } - else - { - _view = HealthModelView.Graph; - } - ResolveSelection(); - } - - private void RebuildModel() - { - _live = AspireHealthModelBuilder.Build(_resources.Values); - try - { - var defaults = HealthModelDocuments.Create(_live, _applicationName); - _saved ??= defaults; - if (!_dirty) - { - // AppHost discovery can initially return only the root. Keep the full saved document - // as the baseline so late-arriving resources recover their saved coordinates. - _draft = _savedInBrowser ? HealthModelDocuments.Reconcile(_saved, _live) : defaults; - if (!_savedInBrowser) - { - _saved = defaults; - } - } - else - { - var baseline = _draft! with - { - Entities = [.. _saved.Entities.Concat(_draft!.Entities).GroupBy(e => e.Name, StringComparer.Ordinal).Select(g => g.Last())] - }; - _draft = HealthModelDocuments.Reconcile(baseline, _live); - } - EvaluateDraft(); - _invalidTopology = false; - } - catch (InvalidDataException ex) - { - Logger.LogWarning(ex, "The AppHost topology cannot be represented as a health model."); - _invalidTopology = true; - ShowMessage(nameof(Strings.HealthModelInvalidTopology), error: true); - } - } - - private void EvaluateDraft() - { - _snapshot = HealthModelEvaluator.Evaluate(_draft is null ? _live : HealthModelDocuments.Apply(_live, _draft)); - ResolveSelection(); - } - - private void ResolveSelection() - { - _selectedNode = EntityName is null ? null : _snapshot.AllNodes.FirstOrDefault(n => - n.Name == EntityName || n.Entity.ResourceKey == EntityName); - } - - private void ChangeView(FluentTab tab) - { - if (Enum.TryParse(tab.Id, out var view)) - { - _view = view; - Navigate(); - } - } - - private void SelectEntity(string name) - { - EntityName = name; - ResolveSelection(); - Navigate(); - } - - private void ClearSelectedEntity() - { - EntityName = null; - _selectedNode = null; - Navigate(); - } - - private void Navigate() => NavigationManager.NavigateTo( - DashboardUrls.HealthModelUrl(EntityName, _view.ToString()), replace: true); - - private void ToggleState(HealthState state) => _stateFilter = _stateFilter == state ? null : state; - - private void ChangeDraft(HealthModelDocument document) - { - if (!CanEdit || _draft is null) - { - return; - } - // A bounded undo stack stores only declarative configuration, never live resource data. - if (_undo.Count >= 20) - { - var recent = _undo.Take(19).Reverse().ToArray(); - _undo.Clear(); - foreach (var item in recent) - { - _undo.Push(item); - } - } - _undo.Push(_draft); - _draft = document; - _dirty = HealthModelDocuments.Serialize(_draft) != HealthModelDocuments.Serialize(HealthModelDocuments.Reconcile(_saved!, _live)); - _message = null; - EvaluateDraft(); - } - - private Task MoveEntity(HealthModelPositionChange change) - { - if (IsDesigner && _draft is not null && _draft.Entities.Any(e => e.Name == change.Name)) - { - try - { - var position = HealthModelLayout.Place(_draft, change.Name, new HealthModelCanvasPosition(change.X, change.Y)); - ChangeDraft(_draft with - { - Entities = [.. _draft.Entities.Select(e => e.Name == change.Name ? e with { CanvasPosition = position } : e)] - }); - } - catch (InvalidDataException ex) - { - Logger.LogWarning(ex, "Invalid position for health model entity '{EntityName}'.", change.Name); - ShowMessage(nameof(Strings.HealthModelInvalidSettings), error: true); - } - } - return Task.CompletedTask; - } - - private void ApplyEntity(HealthModelEntityConfiguration configuration) - { - if (!IsDesigner || _draft is null) - { - return; - } - try - { - var position = HealthModelLayout.Place(_draft, configuration.Name, configuration.CanvasPosition); - var updated = _draft with - { - Entities = [.. _draft.Entities.Select(e => e.Name == configuration.Name ? configuration with { CanvasPosition = position } : e)] - }; - HealthModelDocuments.Validate(updated, _applicationName); - ChangeDraft(updated); - } - catch (InvalidDataException ex) - { - Logger.LogWarning(ex, "Invalid health model entity configuration."); - ShowMessage(nameof(Strings.HealthModelInvalidSettings), error: true); - } - } - - private void Arrange() - { - if (!IsDesigner || _draft is null) - { - return; - } - var positions = HealthModelLayout.Arrange(_live); - ChangeDraft(_draft with { Entities = [.. _draft.Entities.Select(e => e with { CanvasPosition = positions[e.Name] })] }); - _layoutVersion++; - } - - private void Undo() - { - if (!IsDesigner || !_undo.TryPop(out var document)) - { - return; - } - _draft = HealthModelDocuments.Reconcile(document, _live); - _dirty = HealthModelDocuments.Serialize(_draft) != HealthModelDocuments.Serialize(HealthModelDocuments.Reconcile(_saved!, _live)); - EvaluateDraft(); - } - - private void Discard() - { - if (!CanEdit || _saved is null) - { - return; - } - _draft = HealthModelDocuments.Reconcile(_saved, _live); - _dirty = false; - _undo.Clear(); - _message = null; - _layoutVersion++; - EvaluateDraft(); - } - - private async Task SaveAsync() - { - if (!CanEdit || _draft is null) - { - return; - } - _saving = true; - var saving = _draft; - try - { - HealthModelDocuments.Validate(saving, _applicationName); - await LocalStorage.SetAsync(StorageKey, saving); - _saved = saving; - _savedInBrowser = true; - _dirty = false; - _undo.Clear(); - ShowMessage(nameof(Strings.HealthModelSaveSuccess), error: false); - } - catch (Exception ex) when (ex is JSException or InvalidDataException or JsonException) - { - Logger.LogError(ex, "Failed to save the health model."); - ShowMessage(nameof(Strings.HealthModelSaveError), error: true); - } - finally - { - _saving = false; - } - } - - private async Task ExportAsync() - { - if (_saved is null || _dirty || _invalidTopology) - { - return; - } - try - { - var document = HealthModelDocuments.Reconcile(_saved, _live); - HealthModelDocuments.Validate(document, _applicationName); - await JS.DownloadFileAsync("aspire-healthmodel.json", HealthModelDocuments.Serialize(document)); - } - catch (Exception ex) when (ex is JSException or InvalidDataException) - { - Logger.LogError(ex, "Failed to export the health model."); - ShowMessage(nameof(Strings.HealthModelExportError), error: true); - } - } - - private async Task ImportAsync(InputFileChangeEventArgs args) - { - if (!CanEdit || _draft is null) - { - return; - } - try - { - await using var stream = args.File.OpenReadStream(HealthModelDocuments.MaxFileSize, _cts.Token); - using var reader = new StreamReader(stream); - var json = await reader.ReadToEndAsync(_cts.Token); - var document = HealthModelDocuments.Deserialize(json, _draft); - ChangeDraft(document); - _view = HealthModelView.Designer; - _layoutVersion++; - Navigate(); - ShowMessage(nameof(Strings.HealthModelImportSuccess), error: false); - } - catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException) - { - Logger.LogWarning(ex, "Failed to import the health model."); - ShowMessage(nameof(Strings.HealthModelImportError), error: true); - } - } - - private void ShowMessage(string key, bool error) - { - _message = Loc[key]; - _isError = error; - } - - private string StateLabel(HealthState state) => HealthModelLabels.State(state, Loc); - private string SignalSummary(HealthModelNode node) => Loc[nameof(Strings.HealthModelSignalCount), - node.Entity.Signals.Count(s => s.State == HealthState.Healthy), node.Entity.Signals.Length]; - - internal static string GetRollupDescription(DependenciesAggregation aggregation, IStringLocalizer loc) => - HealthModelLabels.Aggregation(aggregation.AggregationType, loc); - - public async ValueTask DisposeAsync() - { - _disposed = true; - await _cts.CancelAsync(); - await TaskHelpers.WaitIgnoreCancelAsync(_subscriptionTask); - _cts.Dispose(); - } - - private enum HealthModelView { Graph, Entities, Designer } -} diff --git a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css b/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css deleted file mode 100644 index 88676f4e1a0..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/HealthModel.razor.css +++ /dev/null @@ -1,43 +0,0 @@ -::deep .health-model-layout { - display: flex; - flex-direction: column; - height: 100%; - min-height: 500px; - overflow: hidden; -} - -.health-model-header, .health-model-tabs-row, .health-model-overview { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; - padding: 8px 16px; - border-bottom: 1px solid var(--neutral-stroke-divider-rest); -} - -.health-model-header, .health-model-tabs-row { justify-content: space-between; } -.health-model-title, .health-model-file-actions, .health-model-design-actions, .health-model-state-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.health-model-save-state, .health-model-count { color: var(--foreground-subtext-rest); font-size: 12px; } -.health-model-overview-state { display: inline-flex; align-items: center; gap: 4px; font-weight: 600; } -.health-model-state-filter { display: flex; align-items: center; gap: 6px; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--neutral-foreground-rest); padding: 5px 8px; cursor: pointer; } -.health-model-state-filter[aria-pressed="true"] { border-color: var(--accent-fill-rest); background: var(--neutral-fill-secondary-rest); } -.health-model-dot { width: 8px; height: 8px; background: var(--info); border-radius: 50%; } -[data-health="Healthy"] .health-model-dot { background: var(--aspire-status-success); } -[data-health="Degraded"] .health-model-dot { background: var(--aspire-status-warning); } -[data-health="Unhealthy"] .health-model-dot { background: var(--aspire-status-error); } -.health-model-hint { margin: 0; padding: 8px 16px; color: var(--foreground-subtext-rest); font-size: 12px; } -.health-model-main { flex: 1; min-height: 0; } -::deep .health-model-grid-container { height: 100%; overflow: auto; } -::deep .health-model-entity-link { background: none; border: 0; color: var(--accent-foreground-rest); cursor: pointer; text-align: left; padding: 0; font: inherit; } -.health-model-message { padding: 8px 16px; background: var(--neutral-fill-secondary-rest); } -.health-model-message.is-error { border-left: 3px solid var(--aspire-status-error); } -.health-model-scope { padding: 8px 16px; border-top: 1px solid var(--neutral-stroke-divider-rest); color: var(--foreground-subtext-rest); font-size: 12px; } -.health-model-scope summary { cursor: pointer; } -.health-model-import { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; } -::deep .health-model-import input { max-width: 200px; } - -@media (max-width: 600px) { - .health-model-layout { min-height: 750px; } - .health-model-main { min-height: 380px; } - .health-model-file-actions { width: 100%; } -} diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css index e86a8c40813..280e57164a8 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css @@ -447,7 +447,7 @@ the node surface. The outline has no competing !important rule, so it survives hover and selection and the health signal is never lost. */ ::deep .resource-group[data-health="Healthy"] .resource-node { - fill: color-mix(in srgb, var(--aspire-status-success) 30%, var(--fill-color)); + fill: color-mix(in srgb, var(--aspire-status-success) 30%, var(--aspire-page-background)); } ::deep .resource-group[data-health="Healthy"] .resource-node-border { @@ -456,7 +456,7 @@ } ::deep .resource-group[data-health="Degraded"] .resource-node { - fill: color-mix(in srgb, var(--aspire-status-warning) 30%, var(--fill-color)); + fill: color-mix(in srgb, var(--aspire-status-warning) 30%, var(--aspire-page-background)); } ::deep .resource-group[data-health="Degraded"] .resource-node-border { @@ -465,7 +465,7 @@ } ::deep .resource-group[data-health="Unhealthy"] .resource-node { - fill: color-mix(in srgb, var(--aspire-status-error) 30%, var(--fill-color)); + fill: color-mix(in srgb, var(--aspire-status-error) 30%, var(--aspire-page-background)); } ::deep .resource-group[data-health="Unhealthy"] .resource-node-border { diff --git a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs index dd097d79323..9271041a1ad 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/AspireHealthModelBuilder.cs @@ -1,138 +1,15 @@ // 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.Immutable; -using System.Globalization; -using System.IO.Hashing; -using System.Text; -using Aspire.Dashboard.Model.ResourceGraph; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Dashboard.Model.HealthModel; /// -/// Projects the live Aspire application model into a . +/// Maps Aspire resource state into , shared by the resource graph's health rollup. /// -/// -/// Uses the same AppHost relationships as the resource graph. All entities have standard impact and -/// worst-of rollup initially; the designer can explicitly change those settings. -/// public static class AspireHealthModelBuilder { - /// The name of the health model, which is also the name of its root entity. - public const string RootEntityName = "aspire-app-health"; - - /// The name of the signal projected from a resource's lifecycle state. - public const string ResourceStateSignalName = "resource-state"; - - /// - /// Builds the dependency model from a set of resources. - /// - /// The resources currently known to the dashboard. - public static HealthModelDefinition Build(IEnumerable resources) - { - ArgumentNullException.ThrowIfNull(resources); - - var entities = ImmutableArray.CreateBuilder(); - var relationships = ImmutableArray.CreateBuilder(); - var modelResources = resources - .Where(r => !r.IsResourceHidden(showHiddenResources: false)) - .Where(r => r.ResourceType is not (KnownResourceTypes.Parameter or KnownResourceTypes.ConnectionString)) - .OrderBy(r => r.PersistentKey, StringComparers.ResourceName) - .ToArray(); - var entityNames = modelResources.ToDictionary(r => r.Name, GetEntityName, StringComparers.ResourceName); - - entities.Add(new HealthModelEntity - { - Name = RootEntityName, - DisplayName = "AppHost", - Category = "AppHost", - Dependencies = DependenciesAggregation.WorstOf - }); - - foreach (var resource in modelResources) - { - entities.Add(CreateResourceEntity(resource)); - } - - var edges = ResourceGraphHealth.BuildEdges(modelResources, showHiddenResources: false); - foreach (var name in ResourceGraphHealth.GetRootNames(modelResources, edges)) - { - relationships.Add(new HealthModelRelationship(RootEntityName, entityNames[name])); - } - foreach (var edge in edges) - { - relationships.Add(new HealthModelRelationship(entityNames[edge.ParentName], entityNames[edge.ChildName])); - } - - return new HealthModelDefinition - { - Name = RootEntityName, - DisplayName = "Application health", - Entities = entities.ToImmutable(), - Relationships = [.. relationships.OrderBy(r => r.ParentEntityName, StringComparer.Ordinal).ThenBy(r => r.ChildEntityName, StringComparer.Ordinal)] - }; - } - - /// - /// Gets the entity name used for a resource. - /// - /// - /// Uses the resource's persistent key rather than its name because the name includes a randomly - /// generated suffix that changes every time the app host restarts, which would churn entity identity - /// across restarts and break any deployed model that references it. - /// - public static string GetEntityName(ResourceViewModel resource) - { - ArgumentNullException.ThrowIfNull(resource); - - // Preserve identity across runtime suffix changes and use only Azure-valid characters. The - // non-cryptographic suffix distinguishes display names that normalize to the same readable slug. - var slug = new string(resource.DisplayName.ToLowerInvariant() - .Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-').Take(48).ToArray()).Trim('-'); - var hash = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(resource.PersistentKey.ToLowerInvariant())); - return $"resource-{slug}-{hash.ToString("x16", CultureInfo.InvariantCulture)}"; - } - - private static HealthModelEntity CreateResourceEntity(ResourceViewModel resource) - { - var signals = ImmutableArray.CreateBuilder(resource.HealthReports.Length + 1); - - signals.Add(new HealthModelSignal - { - Name = ResourceStateSignalName, - DisplayName = "Resource state", - Kind = SignalKind.External, - ReportedState = MapResourceState(resource.KnownState), - Description = resource.State - }); - - foreach (var report in resource.HealthReports) - { - signals.Add(new HealthModelSignal - { - Name = report.Name, - DisplayName = report.Name, - Kind = SignalKind.External, - ReportedState = MapHealthStatus(report.HealthStatus), - Description = report.Description ?? report.ExceptionText - }); - } - - return new HealthModelEntity - { - Name = GetEntityName(resource), - DisplayName = resource.DisplayName, - Category = resource.ResourceType, - ResourceName = resource.Name, - ResourceKey = resource.PersistentKey, - AspireResourceName = resource.DisplayName, - ReplicaIndex = resource.ReplicaIndex, - ResourceType = resource.ResourceType, - Signals = signals.ToImmutable() - }; - } - /// /// Maps an Aspire resource lifecycle state to a health state. /// diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs deleted file mode 100644 index 209d1940206..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelDocument.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Dashboard.Model.HealthModel; - -internal static class HealthModelDocuments -{ - public const int MaxFileSize = HealthModelContract.MaxFileSize; - - public static HealthModelDocument Create(HealthModelDefinition definition, string applicationName) - { - var positions = HealthModelLayout.Arrange(definition); - return new HealthModelDocument - { - Name = definition.Name, - ApplicationName = applicationName, - Entities = [.. definition.Entities.Select(entity => new HealthModelEntityConfiguration - { - Name = entity.Name, - DisplayName = entity.DisplayName ?? entity.Name, - AspireResourceName = entity.AspireResourceName, - ReplicaIndex = entity.ReplicaIndex, - CanvasPosition = positions[entity.Name], - Impact = entity.Impact, - Dependencies = entity.Dependencies, - HealthObjective = entity.HealthObjective, - LocalSignals = [.. entity.Signals.Select(s => new HealthModelSignalBinding(s.Name, s.Kind))] - })], - Relationships = definition.Relationships - }; - } - - public static HealthModelDocument Reconcile(HealthModelDocument saved, HealthModelDefinition definition) - { - var current = Create(definition, saved.ApplicationName); - var savedEntities = saved.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); - return current with - { - Entities = [.. current.Entities.Select(entity => - savedEntities.TryGetValue(entity.Name, out var previous) - ? entity with - { - CanvasPosition = previous.CanvasPosition, - DisplayName = previous.DisplayName, - Impact = previous.Impact, - Dependencies = previous.Dependencies, - HealthObjective = previous.HealthObjective - } - : entity)] - }; - } - - public static HealthModelDefinition Apply(HealthModelDefinition live, HealthModelDocument document) - { - var settings = document.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); - return live with - { - Entities = [.. live.Entities.Select(entity => settings.TryGetValue(entity.Name, out var config) - ? entity with { DisplayName = config.DisplayName, Impact = config.Impact, Dependencies = config.Dependencies, HealthObjective = config.HealthObjective } - : entity)] - }; - } - - public static string Serialize(HealthModelDocument document) => HealthModelContract.Serialize(document); - - public static HealthModelDocument Deserialize(string json, HealthModelDocument current) - { - var document = HealthModelContract.Deserialize(json); - ValidateApplication(document, current.ApplicationName); - if (document.Name != current.Name || - !document.Entities.Select(e => (e.Name, e.AspireResourceName, e.ReplicaIndex)).ToHashSet() - .SetEquals(current.Entities.Select(e => (e.Name, e.AspireResourceName, e.ReplicaIndex))) || - !document.Relationships.ToHashSet().SetEquals(current.Relationships)) - { - throw new InvalidDataException("The imported topology does not match this AppHost. Relationships are defined in the AppHost, not the designer."); - } - var currentEntities = current.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); - if (document.Entities.Any(e => !e.LocalSignals.ToHashSet().SetEquals(currentEntities[e.Name].LocalSignals))) - { - throw new InvalidDataException("Local signal bindings are defined by the AppHost and cannot be changed in an imported layout."); - } - return document; - } - - public static void Validate(HealthModelDocument document, string applicationName) - { - HealthModelContract.Validate(document); - ValidateApplication(document, applicationName); - } - - public static bool IsValidPosition(HealthModelCanvasPosition position) => HealthModelContract.IsValidPosition(position); - - public static void ValidateAggregation(DependenciesAggregation aggregation) => HealthModelContract.ValidateAggregation(aggregation); - - private static void ValidateApplication(HealthModelDocument document, string applicationName) - { - if (document.ApplicationName != applicationName) - { - throw new InvalidDataException("The model application does not match this AppHost."); - } - } -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs deleted file mode 100644 index ab69416d636..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEntity.cs +++ /dev/null @@ -1,105 +0,0 @@ -// 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.Immutable; - -namespace Aspire.Dashboard.Model.HealthModel; - -/// -/// A node in a health model. Represents either a real resource or a logical component such as a code -/// component, a user flow, or a team. -/// -/// -/// Uses the common configuration concepts of Microsoft.CloudHealth/healthmodels/entities. -/// identifies a local resource, not an ARM resource ID. A future publisher -/// must supply a deployed resource binding and signal source rather than copying the local name into -/// an Azure resource signal group. -/// -public sealed record HealthModelEntity -{ - /// - /// The name of the entity. Must be unique within the model and is the key used by relationships. - /// - /// - /// Azure constrains entity names to ^[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]$. Names are not - /// validated here because the local model has no such restriction, but keeping to that shape avoids - /// having to rewrite names when the model is translated to Bicep. - /// - public required string Name { get; init; } - - /// The name shown in the UI. Falls back to when not set. - public string? DisplayName { get; init; } - - /// How much of this entity's state is propagated to its parents. - public EntityImpact Impact { get; init; } = EntityImpact.Standard; - - /// - /// The percentage of time the entity is expected to be healthy, between 0 and 100. Informational only - /// in the local model; it maps to healthObjective on translation. - /// - public double? HealthObjective { get; init; } - - /// How this entity combines the health states of its children. - public DependenciesAggregation Dependencies { get; init; } = DependenciesAggregation.WorstOf; - - /// The signals that determine this entity's own state, before dependencies are considered. - public ImmutableArray Signals { get; init; } = []; - - /// The name of the Aspire resource this entity was projected from, when it represents one. - public string? ResourceName { get; init; } - - /// The stable local key used to associate designer settings with a resource replica. - public string? ResourceKey { get; init; } - - /// The AppHost resource name, without the runtime-generated instance suffix. - public string? AspireResourceName { get; init; } - - /// The replica index of the bound AppHost resource. - public int? ReplicaIndex { get; init; } - - /// The type of the Aspire resource this entity was projected from, such as Project. - public string? ResourceType { get; init; } - - /// The name shown in the UI for what this entity represents, such as "Container" or "Service". - public string? Category { get; init; } -} - -/// -/// A complete health model: a set of entities and the relationships that connect them. -/// -/// -/// -/// The shape of this type mirrors Microsoft.CloudHealth/healthmodels and its entities and -/// relationships child resources so the model can be translated to Bicep. and -/// are kept as flat lists rather than a tree for that reason: the Azure model is -/// a graph in which an entity may have several parents, and relationships are standalone resources. -/// -/// -/// Two pieces are still required before a model can be deployed, and neither can be derived from the local -/// app model: an entity that represents a real Azure resource needs the ARM resource ID of its deployed -/// counterpart, and every data-source signal group needs an authenticationsettings resource to read -/// through. Both arrive with deployment information rather than from the running app host. -/// -/// -/// The initial documented publishing target is the 2026-05-01-preview API version. -/// Azure coordinate mapping and execution parity require service-level validation. -/// See https://learn.microsoft.com/azure/azure-monitor/health-models/tutorial-bicep. -/// -/// -public sealed record HealthModelDefinition -{ - /// - /// The name of the model. This is also the name of the root entity, matching the Azure behaviour where - /// the root entity is created automatically with the same name as the health model. - /// - public required string Name { get; init; } - - /// The name shown in the UI. Falls back to when not set. - public string? DisplayName { get; init; } - - /// All entities in the model, including the root entity. - public ImmutableArray Entities { get; init; } = []; - - /// The parent-to-child edges connecting . - public ImmutableArray Relationships { get; init; } = []; -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs deleted file mode 100644 index 8069900c352..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelEvaluator.cs +++ /dev/null @@ -1,135 +0,0 @@ -// 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.Immutable; - -namespace Aspire.Dashboard.Model.HealthModel; - -/// -/// Evaluates a into a by resolving -/// each entity's signals and rolling child health up through the model. -/// -/// -/// The rollup reproduces the Azure Monitor pipeline: each signal is evaluated to a state, the entity takes -/// the worst state across its own signals, each child's state is rewritten by its own -/// , those results are combined using the parent's -/// , and finally the entity's own state and its aggregated dependency -/// state are combined worst-of. -/// See https://learn.microsoft.com/azure/azure-monitor/health-models/rollup. -/// -public static class HealthModelEvaluator -{ - /// Evaluates a model definition. - public static HealthModelSnapshot Evaluate(HealthModelDefinition definition) - { - ArgumentNullException.ThrowIfNull(definition); - - if (definition.Entities.Length == 0) - { - return new HealthModelSnapshot { Definition = definition, Root = null, AllNodes = [] }; - } - - var topology = HealthModelTopology.Create(definition); - var nodes = new Dictionary(StringComparer.Ordinal); - // Evaluate each entity once, from leaves up. Shared dependencies must not produce duplicate - // rows (or duplicate DOM keys) when the same entity is reached through several parents. - foreach (var entity in topology.Order.Reverse()) - { - var children = topology.Children[entity.Name].Select(n => nodes[n]).ToImmutableArray(); - var signalsState = HealthStateExtensions.WorstOf(entity.Signals.Select(s => s.State)); - HealthState? dependenciesState = children.Length == 0 - ? null - : AggregateDependencies(entity.Dependencies, children); - var state = HealthStateExtensions.WorstOf(signalsState, dependenciesState ?? HealthState.Unknown); - - nodes.Add(entity.Name, new HealthModelNode - { - Entity = entity, - State = state, - SignalsState = signalsState, - DependenciesState = dependenciesState, - Children = children, - Depth = topology.Depth[entity.Name] - }); - } - - var root = nodes.GetValueOrDefault(definition.Name) ?? nodes[topology.Order[0].Name]; - var allNodes = ImmutableArray.CreateBuilder(); - var visited = new HashSet(StringComparer.Ordinal); - var pending = new Stack([root]); - while (pending.TryPop(out var node)) - { - if (visited.Add(node.Name)) - { - allNodes.Add(node); - foreach (var child in node.Children.Reverse()) - { - pending.Push(child); - } - } - } - foreach (var entity in topology.Order.Where(e => !visited.Contains(e.Name))) - { - allNodes.Add(nodes[entity.Name]); - } - - return new HealthModelSnapshot { Definition = definition, Root = root, AllNodes = allNodes.ToImmutable() }; - } - - /// - /// Combines the states of an entity's children into the single state they contribute to their parent. - /// - internal static HealthState AggregateDependencies(DependenciesAggregation aggregation, ImmutableArray children) - { - // Each child's state is first rewritten by its own impact, then fed into the parent's aggregation. - var memberStates = children.Select(c => c.State.ApplyImpact(c.Entity.Impact)); - - if (aggregation.AggregationType == DependenciesAggregationType.WorstOf) - { - return HealthStateExtensions.WorstOf(memberStates); - } - - var members = aggregation.IgnoreUnknown - ? memberStates.Where(s => s != HealthState.Unknown).ToList() - : memberStates.ToList(); - - if (members.Count == 0) - { - return HealthState.Unknown; - } - - var healthyCount = members.Count(s => s == HealthState.Healthy); - - // MinHealthy counts what is working, MaxNotHealthy counts what is broken. The two therefore breach - // in opposite directions, which is handled by IsBreached below. - var measured = aggregation.AggregationType == DependenciesAggregationType.MinHealthy - ? healthyCount - : members.Count - healthyCount; - - var value = aggregation.Unit == AggregationUnit.Percentage - ? measured * 100d / members.Count - : measured; - - if (aggregation.UnhealthyThreshold is { } unhealthyThreshold && IsBreached(value, unhealthyThreshold)) - { - return HealthState.Unhealthy; - } - - if (aggregation.DegradedThreshold is { } degradedThreshold && IsBreached(value, degradedThreshold)) - { - return HealthState.Degraded; - } - - return HealthState.Healthy; - - bool IsBreached(double measuredValue, double threshold) => aggregation.AggregationType switch - { - // "At least N children must be healthy" breaches once the healthy count falls to or below N. - DependenciesAggregationType.MinHealthy => measuredValue <= threshold, - // "No more than N children may be unhealthy" breaches once the not-healthy count reaches N. - DependenciesAggregationType.MaxNotHealthy => measuredValue >= threshold, - _ => false - }; - } - -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs deleted file mode 100644 index 14c60ab9267..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelLabels.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Localization; -using Strings = Aspire.Dashboard.Resources.HealthModel; - -namespace Aspire.Dashboard.Model.HealthModel; - -internal static class HealthModelLabels -{ - public static string State(HealthState state, IStringLocalizer loc) => loc[state switch - { - HealthState.Healthy => nameof(Strings.HealthModelHealthy), - HealthState.Degraded => nameof(Strings.HealthModelDegraded), - HealthState.Unhealthy => nameof(Strings.HealthModelUnhealthy), - _ => nameof(Strings.HealthModelUnknown) - }]; - - public static string Impact(EntityImpact impact, IStringLocalizer loc) => loc[impact switch - { - EntityImpact.Limited => nameof(Strings.HealthModelImpactLimited), - EntityImpact.Suppressed => nameof(Strings.HealthModelImpactSuppressed), - _ => nameof(Strings.HealthModelImpactStandard) - }]; - - public static string Aggregation(DependenciesAggregationType type, IStringLocalizer loc) => loc[type switch - { - DependenciesAggregationType.MinHealthy => nameof(Strings.HealthModelMinimumHealthy), - DependenciesAggregationType.MaxNotHealthy => nameof(Strings.HealthModelMaximumNotHealthy), - _ => nameof(Strings.HealthModelRollupWorstOf) - }]; -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs deleted file mode 100644 index ef6735a4db5..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelLayout.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Dashboard.Model.HealthModel; - -internal static class HealthModelLayout -{ - public const double CardWidth = 224; - public const double CardHeight = 104; - private const double ColumnSpacing = 272; - private const double RowSpacing = 180; - - public static Dictionary Arrange(HealthModelDefinition definition) - { - var topology = HealthModelTopology.Create(definition); - var positions = new Dictionary(StringComparer.Ordinal); - var entities = definition.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); - var parents = definition.Relationships.GroupBy(r => r.ChildEntityName, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.Select(r => r.ParentEntityName) - .OrderByDescending(n => topology.Depth[n]).ThenBy(n => n, StringComparer.Ordinal).First(), StringComparer.Ordinal); - var treeChildren = parents.ToLookup(pair => pair.Value, pair => pair.Key, StringComparer.Ordinal); - var roots = topology.Order.Where(e => !parents.ContainsKey(e.Name)).Select(e => e.Name).ToArray(); - var pending = new Stack<(string Name, bool Visited)>(Enumerable.Reverse(roots).Select(n => (n, false))); - var nextSlot = 0; - while (pending.TryPop(out var item)) - { - var children = treeChildren[item.Name] - .OrderBy(n => entities[n].DisplayName ?? n, StringComparer.Ordinal).ToArray(); - if (children.Length == 0) - { - positions[item.Name] = new(nextSlot++ * ColumnSpacing, topology.Depth[item.Name] * RowSpacing); - } - else if (item.Visited) - { - // A shared dependency has one positioning parent but keeps every relationship. Centre - // parents over their own branch instead of alphabetizing unrelated nodes across rows. - positions[item.Name] = new( - (positions[children[0]].X + positions[children[^1]].X) / 2, - topology.Depth[item.Name] * RowSpacing); - } - else - { - pending.Push((item.Name, true)); - foreach (var child in Enumerable.Reverse(children)) - { - pending.Push((child, false)); - } - } - } - var offset = positions.Count == 0 ? 0 : (positions.Values.Min(p => p.X) + positions.Values.Max(p => p.X)) / 2; - return positions.ToDictionary(pair => pair.Key, pair => pair.Value with { X = pair.Value.X - offset }, StringComparer.Ordinal); - } - - public static HealthModelCanvasPosition Place(HealthModelDocument document, string name, HealthModelCanvasPosition requested) - { - if (!HealthModelDocuments.IsValidPosition(requested)) - { - throw new InvalidDataException("The canvas position must contain finite coordinates within the supported canvas."); - } - var otherPositions = document.Entities.Where(e => e.Name != name).Select(e => e.CanvasPosition).ToArray(); - var snapped = new HealthModelCanvasPosition(Math.Round(requested.X / 8) * 8, Math.Round(requested.Y / 8) * 8); - if (IsFree(snapped)) - { - return snapped; - } - - // Keep the other saved positions intact. Find a nearby free slot for the dropped card instead - // of letting a force simulation rearrange positions that must round-trip to Azure. - for (var radius = 1; radius <= document.Entities.Length + 1; radius++) - { - for (var x = -radius; x <= radius; x++) - { - foreach (var y in new[] { -radius, radius }) - { - var candidate = new HealthModelCanvasPosition(snapped.X + x * ColumnSpacing, snapped.Y + y * RowSpacing); - if (IsFree(candidate)) - { - return candidate; - } - } - } - } - throw new InvalidDataException("There is no available position on the canvas."); - - bool IsFree(HealthModelCanvasPosition position) => HealthModelDocuments.IsValidPosition(position) && - otherPositions.All(other => Math.Abs(other.X - position.X) >= CardWidth + 16 || Math.Abs(other.Y - position.Y) >= CardHeight + 16); - } -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs deleted file mode 100644 index 5421e9efc80..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSignal.cs +++ /dev/null @@ -1,128 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Dashboard.Model.HealthModel; - -/// -/// The comparison used to test an observed signal value against a threshold. -/// -/// -/// Values match the SignalOperator enum of the 2026-05-01-preview Azure API version. The -/// Dynamic operator is omitted because it relies on Azure-side anomaly detection that has no local equivalent. -/// -public enum SignalOperator -{ - /// The signal breaches when the observed value is greater than the threshold. - GreaterThan, - - /// The signal breaches when the observed value is less than the threshold. - LessThan, - - /// The signal breaches when the observed value is less than or equal to the threshold. - LessThanOrEqual, - - /// The signal breaches when the observed value is greater than or equal to the threshold. - GreaterThanOrEqual, - - /// The signal breaches when the observed value equals the threshold. - Equal, - - /// The signal breaches when the observed value does not equal the threshold. - NotEqual -} - -/// -/// A single comparison that moves a signal into a non-healthy state when it matches. -/// -/// The comparison to apply. -/// The value the observed value is compared against. -public sealed record ThresholdRule(SignalOperator Operator, double Threshold) -{ - /// Determines whether breaches this rule. - public bool IsBreached(double value) => Operator switch - { - SignalOperator.GreaterThan => value > Threshold, - SignalOperator.LessThan => value < Threshold, - SignalOperator.LessThanOrEqual => value <= Threshold, - SignalOperator.GreaterThanOrEqual => value >= Threshold, - SignalOperator.Equal => value == Threshold, - SignalOperator.NotEqual => value != Threshold, - _ => false - }; -} - -/// -/// The thresholds that turn an observed signal value into a . -/// -/// -/// Mirrors EvaluationRule in Azure Monitor health models, where the unhealthy rule is required and the -/// degraded rule is optional. When only is set the signal moves straight from -/// healthy to unhealthy with no intermediate degraded state. -/// -/// The rule that moves the signal to . -/// The optional rule that moves the signal to . -public sealed record EvaluationRule(ThresholdRule UnhealthyRule, ThresholdRule? DegradedRule = null) -{ - /// Evaluates against the rules and returns the resulting state. - public HealthState Evaluate(double value) - { - // The unhealthy rule is checked first because both rules can match at once. For example a rule pair - // of "degraded below 100%, unhealthy below 99%" both match at 98% and unhealthy must win. - if (UnhealthyRule.IsBreached(value)) - { - return HealthState.Unhealthy; - } - - if (DegradedRule?.IsBreached(value) is true) - { - return HealthState.Degraded; - } - - return HealthState.Healthy; - } -} - -/// -/// A single health indicator attached to an entity. -/// -/// -/// An entity's own state is the worst state across all of its signals, which is then combined with the -/// rolled up state of its dependencies. -/// -public sealed record HealthModelSignal -{ - /// The name of the signal. Must be unique within its entity. - public required string Name { get; init; } - - /// The name shown in the UI. Falls back to when not set. - public string? DisplayName { get; init; } - - /// The data source the signal reads from. - public SignalKind Kind { get; init; } = SignalKind.External; - - /// - /// The thresholds applied to . When this is the signal - /// is state-reported and is used directly. - /// - public EvaluationRule? EvaluationRules { get; init; } - - /// The most recent numeric value observed for this signal, if it produces one. - public double? ObservedValue { get; init; } - - /// The unit of , such as Percent or Count. - public string? DataUnit { get; init; } - - /// - /// The state reported directly by the producer. Used when is - /// , which is the case for signals projected from app host health reports. - /// - public HealthState ReportedState { get; init; } = HealthState.Unknown; - - /// Human readable detail about why the signal is in its current state. - public string? Description { get; init; } - - /// The state this signal contributes to its entity. - public HealthState State => EvaluationRules is { } rules && ObservedValue is { } value - ? rules.Evaluate(value) - : ReportedState; -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs deleted file mode 100644 index e6bdf2aa41c..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelSnapshot.cs +++ /dev/null @@ -1,71 +0,0 @@ -// 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.Immutable; - -namespace Aspire.Dashboard.Model.HealthModel; - -/// -/// An entity with its evaluated health state and its place in the model hierarchy. -/// -public sealed class HealthModelNode -{ - /// The entity this node was evaluated from. - public required HealthModelEntity Entity { get; init; } - - /// The final state of the entity, combining its own signals with its dependencies. - public required HealthState State { get; init; } - - /// - /// The worst state across the entity's own signals, or when it has none. - /// Surfaced separately so the UI can show why an entity is unhealthy. - /// - public required HealthState SignalsState { get; init; } - - /// - /// The state contributed by the entity's children after aggregation, or when the - /// entity has no children. - /// - public required HealthState? DependenciesState { get; init; } - - /// The entity's children, already evaluated. - public required ImmutableArray Children { get; init; } - - /// The distance from the root entity. The root itself is zero. - public required int Depth { get; init; } - - /// The unique name of the entity. - public string Name => Entity.Name; - - /// The name to show in the UI. - public string DisplayName => Entity.DisplayName ?? Entity.Name; -} - -/// -/// A fully evaluated health model, ready to render. -/// -public sealed class HealthModelSnapshot -{ - /// An empty model, used before the first resource snapshot arrives. - public static HealthModelSnapshot Empty { get; } = new() - { - Definition = new HealthModelDefinition { Name = "empty" }, - Root = null, - AllNodes = [] - }; - - /// The definition this snapshot was evaluated from. - public required HealthModelDefinition Definition { get; init; } - - /// The root entity of the model, or when the model has no entities. - public required HealthModelNode? Root { get; init; } - - /// - /// Every node in depth-first order. The UI renders the hierarchy as an indented flat list, so this - /// ordering is the render order. - /// - public required ImmutableArray AllNodes { get; init; } - - /// The overall state of the model. - public HealthState State => Root?.State ?? HealthState.Unknown; -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs deleted file mode 100644 index e6455aade7d..00000000000 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthModelTopology.cs +++ /dev/null @@ -1,55 +0,0 @@ -// 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.Immutable; - -namespace Aspire.Dashboard.Model.HealthModel; - -internal sealed class HealthModelTopology -{ - public required ImmutableArray Order { get; init; } - public required ILookup Children { get; init; } - public required Dictionary Depth { get; init; } - - public static HealthModelTopology Create(HealthModelDefinition definition) - { - var entities = definition.Entities.ToDictionary(e => e.Name, StringComparer.Ordinal); - var incoming = entities.Keys.ToDictionary(n => n, _ => 0, StringComparer.Ordinal); - var depth = entities.Keys.ToDictionary(n => n, _ => 0, StringComparer.Ordinal); - var children = definition.Relationships.ToLookup(r => r.ParentEntityName, r => r.ChildEntityName, StringComparer.Ordinal); - var seen = new HashSet(); - foreach (var relationship in definition.Relationships) - { - if (!entities.ContainsKey(relationship.ParentEntityName) || - !entities.ContainsKey(relationship.ChildEntityName) || !seen.Add(relationship)) - { - throw new InvalidDataException("The health model contains a dangling or duplicate relationship."); - } - incoming[relationship.ChildEntityName]++; - } - - var ready = new Queue(definition.Entities.Where(e => incoming[e.Name] == 0).Select(e => e.Name)); - var order = ImmutableArray.CreateBuilder(); - while (ready.TryDequeue(out var name)) - { - order.Add(entities[name]); - foreach (var child in children[name]) - { - depth[child] = Math.Max(depth[child], depth[name] + 1); - if (--incoming[child] == 0) - { - ready.Enqueue(child); - } - } - } - - if (order.Count != entities.Count) - { - // A cyclic AppHost reference graph can still be inspected in Resources, but cannot form - // a health-model hierarchy with unambiguous parent-to-child propagation. - throw new InvalidDataException("Health model relationships must not contain a cycle."); - } - - return new HealthModelTopology { Order = order.ToImmutable(), Children = children, Depth = depth }; - } -} diff --git a/src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs b/src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs index 11030fd819e..1922883feb8 100644 --- a/src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs +++ b/src/Aspire.Dashboard/Model/HealthModel/HealthStateExtensions.cs @@ -43,31 +43,4 @@ public static HealthState WorstOf(IEnumerable states) return worst; } - - /// - /// Applies a child's to the state it reports to its parents. - /// - /// - /// This runs on the child before the parent aggregates its dependencies, so impact and the parent's - /// aggregation compose rather than override one another. - /// - public static HealthState ApplyImpact(this HealthState state, EntityImpact impact) => impact switch - { - EntityImpact.Standard => state, - - // A limited-impact child can never report worse than degraded. Its own degraded state is swallowed - // entirely so that a partially degraded dependency does not visibly degrade the parent. - EntityImpact.Limited => state switch - { - HealthState.Unhealthy => HealthState.Degraded, - HealthState.Degraded => HealthState.Healthy, - _ => state - }, - - // Azure specifies that a suppressed child is always seen as healthy by its parent, including when - // its own state is unknown. - EntityImpact.Suppressed => HealthState.Healthy, - - _ => state - }; } diff --git a/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs b/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs deleted file mode 100644 index 1f02d8be3a1..00000000000 --- a/src/Aspire.Dashboard/Resources/HealthModel.Designer.cs +++ /dev/null @@ -1,425 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Aspire.Dashboard.Resources { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - public class HealthModel { - /// Graph. - public static string HealthModelGraphTab => ResourceManager.GetString("HealthModelGraphTab", resourceCulture); - /// Entities. - public static string HealthModelEntitiesTab => ResourceManager.GetString("HealthModelEntitiesTab", resourceCulture); - /// Designer. - public static string HealthModelDesignerTab => ResourceManager.GetString("HealthModelDesignerTab", resourceCulture); - /// Local preview. - public static string HealthModelLocalPreview => ResourceManager.GetString("HealthModelLocalPreview", resourceCulture); - /// Live health from AppHost dependencies. - public static string HealthModelGraphHint => ResourceManager.GetString("HealthModelGraphHint", resourceCulture); - /// Instructions for editing the model. - public static string HealthModelDesignerHint => ResourceManager.GetString("HealthModelDesignerHint", resourceCulture); - /// Save changes. - public static string HealthModelSave => ResourceManager.GetString("HealthModelSave", resourceCulture); - /// Discard changes. - public static string HealthModelDiscard => ResourceManager.GetString("HealthModelDiscard", resourceCulture); - /// Arrange. - public static string HealthModelArrange => ResourceManager.GetString("HealthModelArrange", resourceCulture); - /// Undo. - public static string HealthModelUndo => ResourceManager.GetString("HealthModelUndo", resourceCulture); - /// Fit to view. - public static string HealthModelFit => ResourceManager.GetString("HealthModelFit", resourceCulture); - /// Zoom in. - public static string HealthModelZoomIn => ResourceManager.GetString("HealthModelZoomIn", resourceCulture); - /// Zoom out. - public static string HealthModelZoomOut => ResourceManager.GetString("HealthModelZoomOut", resourceCulture); - /// Export model. - public static string HealthModelExport => ResourceManager.GetString("HealthModelExport", resourceCulture); - /// Import model. - public static string HealthModelImport => ResourceManager.GetString("HealthModelImport", resourceCulture); - /// Unsaved changes. - public static string HealthModelUnsaved => ResourceManager.GetString("HealthModelUnsaved", resourceCulture); - /// Saved in this browser. - public static string HealthModelSaved => ResourceManager.GetString("HealthModelSaved", resourceCulture); - /// Default layout. - public static string HealthModelDefaultLayout => ResourceManager.GetString("HealthModelDefaultLayout", resourceCulture); - /// Confirmation after saving the model. - public static string HealthModelSaveSuccess => ResourceManager.GetString("HealthModelSaveSuccess", resourceCulture); - /// Error saving the model. - public static string HealthModelSaveError => ResourceManager.GetString("HealthModelSaveError", resourceCulture); - /// Error loading the saved model. - public static string HealthModelLoadError => ResourceManager.GetString("HealthModelLoadError", resourceCulture); - /// Error importing a model. - public static string HealthModelImportError => ResourceManager.GetString("HealthModelImportError", resourceCulture); - /// Confirmation after importing into the draft. - public static string HealthModelImportSuccess => ResourceManager.GetString("HealthModelImportSuccess", resourceCulture); - /// Error downloading the model. - public static string HealthModelExportError => ResourceManager.GetString("HealthModelExportError", resourceCulture); - /// Unsupported AppHost topology. - public static string HealthModelInvalidTopology => ResourceManager.GetString("HealthModelInvalidTopology", resourceCulture); - /// Resource updates have stopped. - public static string HealthModelSubscriptionError => ResourceManager.GetString("HealthModelSubscriptionError", resourceCulture); - /// Historical models are read-only. - public static string HealthModelReadOnly => ResourceManager.GetString("HealthModelReadOnly", resourceCulture); - /// Local health and explicitly configured Azure publishing are separate. - public static string HealthModelCloudBoundary => ResourceManager.GetString("HealthModelCloudBoundary", resourceCulture); - /// Save the definition in the AppHost and configure a matching publisher and metrics producer. - public static string HealthModelDefinitionHint => ResourceManager.GetString("HealthModelDefinitionHint", resourceCulture); - /// Entity and relationship counts. - public static string HealthModelEntityCount => ResourceManager.GetString("HealthModelEntityCount", resourceCulture); - /// Healthy. - public static string HealthModelHealthy => ResourceManager.GetString("HealthModelHealthy", resourceCulture); - /// Degraded. - public static string HealthModelDegraded => ResourceManager.GetString("HealthModelDegraded", resourceCulture); - /// Unhealthy. - public static string HealthModelUnhealthy => ResourceManager.GetString("HealthModelUnhealthy", resourceCulture); - /// Unknown. - public static string HealthModelUnknown => ResourceManager.GetString("HealthModelUnknown", resourceCulture); - /// All health states. - public static string HealthModelAllStates => ResourceManager.GetString("HealthModelAllStates", resourceCulture); - /// No matching entities. - public static string HealthModelNoMatch => ResourceManager.GetString("HealthModelNoMatch", resourceCulture); - /// Display name. - public static string HealthModelDisplayName => ResourceManager.GetString("HealthModelDisplayName", resourceCulture); - /// Entity ID. - public static string HealthModelEntityId => ResourceManager.GetString("HealthModelEntityId", resourceCulture); - /// Canvas X. - public static string HealthModelPositionX => ResourceManager.GetString("HealthModelPositionX", resourceCulture); - /// Canvas Y. - public static string HealthModelPositionY => ResourceManager.GetString("HealthModelPositionY", resourceCulture); - /// Health objective. - public static string HealthModelHealthObjective => ResourceManager.GetString("HealthModelHealthObjective", resourceCulture); - /// Health objective limitations. - public static string HealthModelObjectiveHint => ResourceManager.GetString("HealthModelObjectiveHint", resourceCulture); - /// Standard impact. - public static string HealthModelImpactStandard => ResourceManager.GetString("HealthModelImpactStandard", resourceCulture); - /// Limited impact. - public static string HealthModelImpactLimited => ResourceManager.GetString("HealthModelImpactLimited", resourceCulture); - /// Suppressed impact. - public static string HealthModelImpactSuppressed => ResourceManager.GetString("HealthModelImpactSuppressed", resourceCulture); - /// How impact affects parents. - public static string HealthModelImpactHint => ResourceManager.GetString("HealthModelImpactHint", resourceCulture); - /// Minimum healthy. - public static string HealthModelMinimumHealthy => ResourceManager.GetString("HealthModelMinimumHealthy", resourceCulture); - /// Maximum not healthy. - public static string HealthModelMaximumNotHealthy => ResourceManager.GetString("HealthModelMaximumNotHealthy", resourceCulture); - /// Entity count. - public static string HealthModelAbsolute => ResourceManager.GetString("HealthModelAbsolute", resourceCulture); - /// Percentage. - public static string HealthModelPercentage => ResourceManager.GetString("HealthModelPercentage", resourceCulture); - /// Threshold unit. - public static string HealthModelThresholdUnit => ResourceManager.GetString("HealthModelThresholdUnit", resourceCulture); - /// Optional degraded threshold. - public static string HealthModelDegradedThreshold => ResourceManager.GetString("HealthModelDegradedThreshold", resourceCulture); - /// Unhealthy threshold. - public static string HealthModelUnhealthyThreshold => ResourceManager.GetString("HealthModelUnhealthyThreshold", resourceCulture); - /// Ignore unknown dependencies. - public static string HealthModelIgnoreUnknown => ResourceManager.GetString("HealthModelIgnoreUnknown", resourceCulture); - /// Threshold evaluation directions. - public static string HealthModelThresholdHint => ResourceManager.GetString("HealthModelThresholdHint", resourceCulture); - /// Apply to draft. - public static string HealthModelApply => ResourceManager.GetString("HealthModelApply", resourceCulture); - /// Invalid entity settings. - public static string HealthModelInvalidSettings => ResourceManager.GetString("HealthModelInvalidSettings", resourceCulture); - /// Parents. - public static string HealthModelParents => ResourceManager.GetString("HealthModelParents", resourceCulture); - /// Signals reported by the local AppHost. - public static string HealthModelSignalsSource => ResourceManager.GetString("HealthModelSignalsSource", resourceCulture); - /// Health seen by parents. - public static string HealthModelPropagation => ResourceManager.GetString("HealthModelPropagation", resourceCulture); - /// Accessible canvas label. - public static string HealthModelCanvasLabel => ResourceManager.GetString("HealthModelCanvasLabel", resourceCulture); - /// Accessible relationship label. - public static string HealthModelRelationshipLabel => ResourceManager.GetString("HealthModelRelationshipLabel", resourceCulture); - /// Accessible entity label. - public static string HealthModelNodeLabel => ResourceManager.GetString("HealthModelNodeLabel", resourceCulture); - /// Select an entity to edit. - public static string HealthModelFocusDesigner => ResourceManager.GetString("HealthModelFocusDesigner", resourceCulture); - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal HealthModel() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Dashboard.Resources.HealthModel", typeof(HealthModel).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to Dependencies. - /// - public static string HealthModelDependenciesHeader { - get { - return ResourceManager.GetString("HealthModelDependenciesHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Health states roll up from resources to the logical components that depend on them.. - /// - public static string HealthModelDescription { - get { - return ResourceManager.GetString("HealthModelDescription", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Details. - /// - public static string HealthModelDetailsColumnHeader { - get { - return ResourceManager.GetString("HealthModelDetailsColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Entity. - /// - public static string HealthModelEntityColumnHeader { - get { - return ResourceManager.GetString("HealthModelEntityColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Health model. - /// - public static string HealthModelHeader { - get { - return ResourceManager.GetString("HealthModelHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Health. - /// - public static string HealthModelHealthColumnHeader { - get { - return ResourceManager.GetString("HealthModelHealthColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to This entity has no dependencies.. - /// - public static string HealthModelNoDependencies { - get { - return ResourceManager.GetString("HealthModelNoDependencies", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No entities in the health model.. - /// - public static string HealthModelNoEntities { - get { - return ResourceManager.GetString("HealthModelNoEntities", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to This entity has no signals of its own. Its health comes entirely from its dependencies.. - /// - public static string HealthModelNoSignals { - get { - return ResourceManager.GetString("HealthModelNoSignals", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} health model. - /// - public static string HealthModelPageTitle { - get { - return ResourceManager.GetString("HealthModelPageTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to From dependencies. - /// - public static string HealthModelPropertyDependenciesState { - get { - return ResourceManager.GetString("HealthModelPropertyDependenciesState", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Impact. - /// - public static string HealthModelPropertyImpact { - get { - return ResourceManager.GetString("HealthModelPropertyImpact", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Resource. - /// - public static string HealthModelPropertyResource { - get { - return ResourceManager.GetString("HealthModelPropertyResource", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Dependency rollup. - /// - public static string HealthModelPropertyRollup { - get { - return ResourceManager.GetString("HealthModelPropertyRollup", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to From signals. - /// - public static string HealthModelPropertySignalsState { - get { - return ResourceManager.GetString("HealthModelPropertySignalsState", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Rollup. - /// - public static string HealthModelRollupColumnHeader { - get { - return ResourceManager.GetString("HealthModelRollupColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to At most {0} not healthy. - /// - public static string HealthModelRollupMaxNotHealthy { - get { - return ResourceManager.GetString("HealthModelRollupMaxNotHealthy", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to At least {0} healthy. - /// - public static string HealthModelRollupMinHealthy { - get { - return ResourceManager.GetString("HealthModelRollupMinHealthy", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Worst of. - /// - public static string HealthModelRollupWorstOf { - get { - return ResourceManager.GetString("HealthModelRollupWorstOf", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Signal. - /// - public static string HealthModelSignalColumnHeader { - get { - return ResourceManager.GetString("HealthModelSignalColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} of {1} healthy. - /// - public static string HealthModelSignalCount { - get { - return ResourceManager.GetString("HealthModelSignalCount", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Signals. - /// - public static string HealthModelSignalsColumnHeader { - get { - return ResourceManager.GetString("HealthModelSignalsColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Signals. - /// - public static string HealthModelSignalsHeader { - get { - return ResourceManager.GetString("HealthModelSignalsHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to State. - /// - public static string HealthModelStateColumnHeader { - get { - return ResourceManager.GetString("HealthModelStateColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Type. - /// - public static string HealthModelTypeColumnHeader { - get { - return ResourceManager.GetString("HealthModelTypeColumnHeader", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to View resource. - /// - public static string HealthModelViewResource { - get { - return ResourceManager.GetString("HealthModelViewResource", resourceCulture); - } - } - } -} diff --git a/src/Aspire.Dashboard/Resources/HealthModel.resx b/src/Aspire.Dashboard/Resources/HealthModel.resx deleted file mode 100644 index c437ed0032b..00000000000 --- a/src/Aspire.Dashboard/Resources/HealthModel.resx +++ /dev/null @@ -1,266 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - {0} health model - {0} is an application name - - - Health model - - - Health states roll up from resources to the logical components that depend on them. - - - Entity - - - Type - - - Health - - - Signals - - - Rollup - - - No entities in the health model. - - - Signals - - - Dependencies - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - This entity has no dependencies. - - - Signal - - - State - - - Details - - - Impact - - - Dependency rollup - - - From signals - - - From dependencies - - - Resource - - - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Worst of - - - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - View resource - - Graph - Entities - Designer - Local preview - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Save changes - Discard changes - Arrange - Undo - Fit to view - Zoom in - Zoom out - Export model - Import model - Unsaved changes - Saved in this browser - Default layout - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - The model could not be saved. Your changes are still available in the designer. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - The model could not be downloaded. The saved model has not changed. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - This is a historical run. Switch to the live run to edit the model. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - {0} entities, {1} relationships{0} is an entity count, {1} is a relationship count. - Healthy - Degraded - Unhealthy - Unknown - All health states - No entities match the current filter. - Display name - Entity ID - Canvas X - Canvas Y - Health objective (%) - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Standard - Limited - Suppressed - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Minimum healthy - Maximum not healthy - Entity count - Percentage - Threshold unit - Degraded threshold (optional) - Unhealthy threshold - Ignore unknown dependencies - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Apply to draft - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Parents - Signals reported by the local AppHost - Health seen by parents - Application health model. Select an entity to inspect it. - {0} depends on {1}: {2}Parent entity name, child entity name and health state. - {0}: {1}. {2} signals.Entity display name, health state and signal count. - Select an entity to edit its propagation settings and canvas position. - diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index ee30d2c1174..85c7d8f71bc 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -240,15 +240,6 @@ public static string NavMenuConsoleLogsTab { } } - /// - /// Looks up a localized string similar to Health. - /// - public static string NavMenuHealthModelTab { - get { - return ResourceManager.GetString("NavMenuHealthModelTab", resourceCulture); - } - } - /// /// Looks up a localized string similar to Metrics. /// diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 8e531496932..71fdada3c83 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -159,9 +159,6 @@ Metrics - - Health - Expand navigation labels diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf deleted file mode 100644 index 7eb8c13a4b2..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.cs.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf deleted file mode 100644 index ac7a99a4761..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.de.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf deleted file mode 100644 index 091bd562f54..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.es.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf deleted file mode 100644 index 36169f87482..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.fr.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf deleted file mode 100644 index c212cbd3842..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.it.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf deleted file mode 100644 index e808a4aa19f..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ja.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf deleted file mode 100644 index c279df8b644..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ko.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf deleted file mode 100644 index f20851ba438..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pl.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf deleted file mode 100644 index f96129a24f8..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.pt-BR.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf deleted file mode 100644 index f2fde5818e9..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.ru.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf deleted file mode 100644 index 3a4c20aa705..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.tr.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf deleted file mode 100644 index fd68dd91230..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hans.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf deleted file mode 100644 index 6aa9de5ca0f..00000000000 --- a/src/Aspire.Dashboard/Resources/xlf/HealthModel.zh-Hant.xlf +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - Entity count - Entity count - - - - All health states - All health states - - - - Apply to draft - Apply to draft - - - - Arrange - Arrange - - - - Application health model. Select an entity to inspect it. - Application health model. Select an entity to inspect it. - - - - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - Live health here is local. Azure publishing is an explicit AppHost opt-in; this view does not query Azure. Timelines, alerts and discovery are not included. - - - - Default layout - Default layout - - - - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - The exported definition preserves entity identities, relationships, canvas coordinates and propagation settings. Save it in the AppHost project and configure Azure health-model publishing with a matching metrics producer. Azure layout and evaluation parity still require service-level validation. - - - - Degraded - Degraded - - - - Degraded threshold (optional) - Degraded threshold (optional) - - - - Dependencies - Dependencies - - - - Health states roll up from resources to the logical components that depend on them. - Health states roll up from resources to the logical components that depend on them. - - - - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - Drag entities or use arrow keys to position them. Relationships come from the AppHost. Save changes to keep this layout. - - - - Designer - Designer - - - - Details - Details - - - - Discard changes - Discard changes - - - - Display name - Display name - - - - Entities - Entities - - - - Entity - Entity - - - - {0} entities, {1} relationships - {0} entities, {1} relationships - {0} is an entity count, {1} is a relationship count. - - - Entity ID - Entity ID - - - - Export model - Export model - - - - The model could not be downloaded. The saved model has not changed. - The model could not be downloaded. The saved model has not changed. - - - - Fit to view - Fit to view - - - - Select an entity to edit its propagation settings and canvas position. - Select an entity to edit its propagation settings and canvas position. - - - - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - Live health from AppHost dependencies. Select an entity to inspect its signals and health propagation. - - - - Graph - Graph - - - - Health model - Health model - - - - Health - Health - - - - Health objective (%) - Health objective (%) - - - - Healthy - Healthy - - - - Ignore unknown dependencies - Ignore unknown dependencies - - - - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - Standard propagates health unchanged. Limited converts Unhealthy to Degraded and Degraded to Healthy. Suppressed does not affect parents. - - - - Limited - Limited - - - - Standard - Standard - - - - Suppressed - Suppressed - - - - Import model - Import model - - - - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - The model could not be imported. Use a version 1 model exported from this application with the same entities and relationships. Details are in the dashboard logs. - - - - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - Model imported into the designer. Save changes to keep it, or discard to return to the previous model. - - - - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - Check the name, coordinates and health settings. Use a 0-100 health objective, valid thresholds, and a more severe unhealthy threshold than the degraded threshold. - - - - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - This AppHost cannot be represented as a health-model hierarchy. Check for cyclic or duplicate dependencies. The Resources graph can still show the reference graph. - - - - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - The saved model is not valid for this application. The live AppHost topology is shown with its default layout. - - - - Local preview - Local preview - - - - Maximum not healthy - Maximum not healthy - - - - Minimum healthy - Minimum healthy - - - - This entity has no dependencies. - This entity has no dependencies. - - - - No entities in the health model. - No entities in the health model. - - - - No entities match the current filter. - No entities match the current filter. - - - - This entity has no signals of its own. Its health comes entirely from its dependencies. - This entity has no signals of its own. Its health comes entirely from its dependencies. - - - - {0}: {1}. {2} signals. - {0}: {1}. {2} signals. - Entity display name, health state and signal count. - - - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - Target percentage of healthy time. Stored for deployment; local availability history is not measured. - - - - {0} health model - {0} health model - {0} is an application name - - - Parents - Parents - - - - Percentage - Percentage - - - - Canvas X - Canvas X - - - - Canvas Y - Canvas Y - - - - Health seen by parents - Health seen by parents - - - - From dependencies - From dependencies - - - - Impact - Impact - - - - Resource - Resource - - - - Dependency rollup - Dependency rollup - - - - From signals - From signals - - - - This is a historical run. Switch to the live run to edit the model. - This is a historical run. Switch to the live run to edit the model. - - - - {0} depends on {1}: {2} - {0} depends on {1}: {2} - Parent entity name, child entity name and health state. - - - Rollup - Rollup - - - - At most {0} not healthy - At most {0} not healthy - {0} is a count or percentage of child entities that may be unhealthy - - - At least {0} healthy - At least {0} healthy - {0} is a count or percentage of child entities that must be healthy - - - Worst of - Worst of - - - - Save changes - Save changes - - - - The model could not be saved. Your changes are still available in the designer. - The model could not be saved. Your changes are still available in the designer. - - - - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - Model saved. Export it to keep a project-owned copy of the topology, positions and health settings. - - - - Saved in this browser - Saved in this browser - - - - Signal - Signal - - - - {0} of {1} healthy - {0} of {1} healthy - {0} is the number of healthy signals, {1} is the total number of signals - - - Signals - Signals - - - - Signals - Signals - - - - Signals reported by the local AppHost - Signals reported by the local AppHost - - - - State - State - - - - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - Resource updates stopped. Reload the page to reconnect; the displayed health may be stale. - - - - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - Minimum healthy breaches at or below the threshold. Maximum not healthy breaches at or above it. Unhealthy takes precedence. - - - - Threshold unit - Threshold unit - - - - Type - Type - - - - Undo - Undo - - - - Unhealthy - Unhealthy - - - - Unhealthy threshold - Unhealthy threshold - - - - Unknown - Unknown - - - - Unsaved changes - Unsaved changes - - - - View resource - View resource - - - - Zoom in - Zoom in - - - - Zoom out - Zoom out - - - - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index b8802e4e764..f37aeabb244 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -107,11 +107,6 @@ Konzola - - Health - Health - - Metrics Metriky diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 6c241de9f69..65371fef389 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -107,11 +107,6 @@ Konsole - - Health - Health - - Metrics Metriken diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 9252582a591..f350e5f2433 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -107,11 +107,6 @@ Consola - - Health - Health - - Metrics Métricas diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 5d88e651bc9..285623dd25d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -107,11 +107,6 @@ Console - - Health - Health - - Metrics Métriques diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 1f1fa6301c1..cfaea90e6cd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -107,11 +107,6 @@ Console - - Health - Health - - Metrics Metriche diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index 10fb1faf894..b5211f6200e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -107,11 +107,6 @@ コンソール - - Health - Health - - Metrics メトリック diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index b64500b302b..169f1767a2d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -107,11 +107,6 @@ 콘솔 - - Health - Health - - Metrics 메트릭 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 272584a6efb..5d55c300631 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -107,11 +107,6 @@ Konsola - - Health - Health - - Metrics Metryki diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index 33792a6df90..267f4267194 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -107,11 +107,6 @@ Console - - Health - Health - - Metrics Métricas diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 0694e385258..f6bd0827c12 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -107,11 +107,6 @@ Консоль - - Health - Health - - Metrics Метрики diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 4eaedeb527f..7d3730a8fd3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -107,11 +107,6 @@ Konsol - - Health - Health - - Metrics Ölçümler diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 22216bde9e3..ae54b45ad84 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -107,11 +107,6 @@ 控制台 - - Health - Health - - Metrics 指标 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 5cb0d0f7bb8..864d2318264 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -107,11 +107,6 @@ 主控台 - - Health - Health - - Metrics 計量 diff --git a/src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js b/src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js deleted file mode 100644 index a9d84fb9b98..00000000000 --- a/src/Aspire.Dashboard/wwwroot/js/app-healthmodel.js +++ /dev/null @@ -1,145 +0,0 @@ -import './d3.v7.min.js'; - -export function createHealthModelGraph(svg, interop) { - return new HealthModelGraph(svg, interop); -} - -class HealthModelGraph { - constructor(svg, interop) { - this.svg = d3.select(svg); - this.viewport = this.svg.select('.health-model-viewport'); - this.interop = interop; - this.positions = new Map(); - this.editable = false; - this.active = null; - this.disposed = false; - this.manuallyFramed = false; - this.zoom = d3.zoom().scaleExtent([0.05, 3]).on('zoom', event => { - this.viewport.attr('transform', event.transform); - if (event.sourceEvent) this.manuallyFramed = true; - }); - this.svg.call(this.zoom).on('dblclick.zoom', null); - this.observer = new ResizeObserver(() => this.resize()); - this.observer.observe(svg.parentElement); - this.drag = d3.drag() - .filter(event => this.editable && !event.button && !event.ctrlKey && !this.active) - .subject((event, position) => position) - .clickDistance(3) - .on('start', (event) => { - this.active = event.subject; - this.moved = false; - }) - .on('drag', event => { - if (this.disposed || this.active !== event.subject) return; - this.moved = true; - this.active.x = event.x; - this.active.y = event.y; - this.drawPositions(); - }) - .on('end', () => this.finishDrag()); - this.blur = () => { - if (this.active) { - d3.select(window).on('.drag', null); - d3.dragEnable(window, true); - this.finishDrag(); - } - }; - window.addEventListener('blur', this.blur); - this.resize(); - } - - update(positions, editable) { - this.editable = editable; - const topologyChanged = positions.length !== this.positions.size || - positions.some(position => !this.positions.has(position.name)); - const next = new Map(); - for (const value of positions) { - const current = this.positions.get(value.name) || { name: value.name }; - if (current !== this.active) Object.assign(current, value); - next.set(value.name, current); - } - this.positions = next; - this.svg.selectAll('.health-model-entity').each((_, index, elements) => { - const element = elements[index]; - d3.select(element).datum(this.positions.get(element.dataset.entity)) - .call(this.drag) - .on('keydown.health-model', event => { - const position = this.positions.get(element.dataset.entity); - if (!position) return; - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - this.interop.invokeMethodAsync('SelectEntity', position.name); - } else if (this.editable && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { - event.preventDefault(); - const step = event.shiftKey ? 64 : 8; - const x = position.x + (event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0); - const y = position.y + (event.key === 'ArrowDown' ? step : event.key === 'ArrowUp' ? -step : 0); - this.interop.invokeMethodAsync('MoveEntity', position.name, x, y); - } - }); - }); - this.drawPositions(); - if (topologyChanged && !this.manuallyFramed) this.fit(); - } - - async finishDrag() { - const position = this.active; - this.active = null; - if (position && this.moved && !this.disposed) { - this.manuallyFramed = true; - await this.interop.invokeMethodAsync('MoveEntity', position.name, position.x, position.y); - } - } - - drawPositions() { - this.svg.selectAll('.health-model-entity').attr('transform', position => `translate(${position.x},${position.y})`); - this.svg.selectAll('.health-model-edge').attr('d', (_, index, elements) => { - const edge = elements[index]; - const parent = this.positions.get(edge.dataset.parent); - const child = this.positions.get(edge.dataset.child); - const y1 = parent.y + 52, y2 = child.y - 52, middle = (y1 + y2) / 2; - return `M ${parent.x} ${y1} C ${parent.x} ${middle}, ${child.x} ${middle}, ${child.x} ${y2}`; - }); - } - - resize() { - const container = this.svg.node().parentElement; - if (!container.clientWidth || !container.clientHeight) return; - this.svg.attr('viewBox', `0 0 ${container.clientWidth} ${container.clientHeight}`); - if (!this.manuallyFramed && this.positions.size) this.fit(); - } - - fit() { - if (!this.positions.size) return; - const container = this.svg.node().parentElement; - if (!container.clientWidth || !container.clientHeight) return; - const values = [...this.positions.values()]; - const minX = Math.min(...values.map(p => p.x)) - 140; - const minY = Math.min(...values.map(p => p.y)) - 84; - const width = Math.max(...values.map(p => p.x)) + 140 - minX; - const height = Math.max(...values.map(p => p.y)) + 84 - minY; - const scale = Math.min(1, container.clientWidth / width, container.clientHeight / height); - this.manuallyFramed = false; - const transform = d3.zoomIdentity - .translate(container.clientWidth / 2, container.clientHeight / 2) - .scale(scale).translate(-minX - width / 2, -minY - height / 2); - this.svg.call(this.zoom.transform, transform); - } - - zoomBy(factor) { - this.manuallyFramed = true; - this.svg.call(this.zoom.scaleBy, factor); - } - - dispose() { - this.disposed = true; - this.observer.disconnect(); - window.removeEventListener('blur', this.blur); - if (this.active) { - d3.select(window).on('.drag', null); - d3.dragEnable(window, true); - } - this.svg.on('.zoom', null); - this.svg.selectAll('.health-model-entity').on('.drag', null).on('.health-model', null); - } -} diff --git a/src/Shared/DashboardUrls.cs b/src/Shared/DashboardUrls.cs index 78543642ad4..e5d66d67480 100644 --- a/src/Shared/DashboardUrls.cs +++ b/src/Shared/DashboardUrls.cs @@ -15,7 +15,6 @@ internal static class DashboardUrls public const string TracesBasePath = "traces"; public const string LoginBasePath = "login"; public const string HealthBasePath = "health"; - public const string HealthModelBasePath = "healthmodel"; public static string ResourcesUrl(string? resource = null, string? view = null, string? hiddenTypes = null, string? hiddenStates = null, string? hiddenHealthStates = null) { @@ -150,21 +149,6 @@ public static string TraceDetailUrl(string traceId, string? spanId = null) return url; } - public static string HealthModelUrl(string? entity = null, string? view = null) - { - var url = $"/{HealthModelBasePath}"; - if (entity != null) - { - url = AddQueryString(url, "entity", entity); - } - if (view is not null) - { - url = AddQueryString(url, "view", view); - } - - return url; - } - public static string LoginUrl(string? returnUrl = null, string? token = null) { var url = $"/{LoginBasePath}"; diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs deleted file mode 100644 index 0378145d150..00000000000 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/HealthModelTests.cs +++ /dev/null @@ -1,218 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Threading.Channels; -using Aspire.Dashboard.Components.Resize; -using Aspire.Dashboard.Components.Controls; -using Aspire.Dashboard.Components.Tests.Shared; -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Model.HealthModel; -using Aspire.Dashboard.Tests.Shared; -using Aspire.Dashboard.Utils; -using Aspire.Tests.Shared.DashboardModel; -using Bunit; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.JSInterop; -using Xunit; - -namespace Aspire.Dashboard.Components.Tests.Pages; - -[UseCulture("en-US")] -public class HealthModelTests : DashboardTestContext -{ - [Fact] - public void Render_ProjectsAndContainers_ShowsEntityHierarchy() - { - var cut = RenderHealthModelPage( - ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running), - ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running)); - - cut.WaitForAssertion(() => - { - Assert.Equal(3, cut.FindAll(".health-model-entity").Count); - Assert.Collection(cut.FindAll(".health-model-card-name").Select(e => e.TextContent.Trim()).Order(StringComparer.Ordinal), - name => Assert.Equal("AppHost", name), - name => Assert.Equal("api", name), - name => Assert.Equal("cache", name)); - }); - } - - [Fact] - public void Render_AllResourcesRunning_ShowsHealthyOverallState() - { - var cut = RenderHealthModelPage( - ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running)); - - cut.WaitForAssertion(() => - { - var overview = cut.Find(".health-model-overview-state"); - Assert.Equal(nameof(HealthState.Healthy), overview.TextContent.Trim()); - }); - } - - [Fact] - public void Render_FailedContainer_ShowsUnhealthyOverallState() - { - var cut = RenderHealthModelPage( - ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running), - ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.FailedToStart)); - - cut.WaitForAssertion(() => - { - var overview = cut.Find(".health-model-overview-state"); - Assert.Equal(nameof(HealthState.Unhealthy), overview.TextContent.Trim()); - }); - } - - [Fact] - public void Render_NoResources_StillShowsAppHostRoot() - { - var cut = RenderHealthModelPage(); - - cut.WaitForAssertion(() => - { - var overview = cut.Find(".health-model-overview-state"); - Assert.Equal(nameof(HealthState.Unknown), overview.TextContent.Trim()); - Assert.Equal("AppHost", Assert.Single(cut.FindAll(".health-model-card-name")).TextContent.Trim()); - }); - } - - [Fact] - public void Render_SelectedEntity_ShowsSignalsInDetailsPane() - { - var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); - var dashboardClient = new TestDashboardClient( - isEnabled: true, - initialResources: - [ - ModelTestHelpers.CreateResource( - resourceName: "api", - displayName: "api", - resourceType: KnownResourceTypes.Project, - state: KnownResourceState.Running, - healthReports: [new HealthReportViewModel("live", HealthStatus.Degraded, "Warming up", null)]) - ], - resourceChannelProvider: Channel.CreateUnbounded>); - - HealthModelSetupHelpers.Setup(this, viewport, dashboardClient); - - // The selected entity is a query string parameter, so navigate to the deep link rather than - // supplying the parameter directly. This also exercises the real deep-link path. - var navigationManager = Services.GetRequiredService(); - navigationManager.NavigateTo(DashboardUrls.HealthModelUrl("api_0")); - - var cut = RenderComponent(builder => - { - builder.AddCascadingValue(viewport); - }); - - cut.WaitForAssertion(() => - { - var details = cut.FindComponent(); - var markup = details.Markup; - - Assert.Contains("Resource state", markup, StringComparison.Ordinal); - Assert.Contains("live", markup, StringComparison.Ordinal); - Assert.Contains("Warming up", markup, StringComparison.Ordinal); - }); - } - - private IRenderedComponent RenderHealthModelPage(params ResourceViewModel[] resources) - => RenderHealthModelPage("Graph", null, resources); - - [Fact] - public async Task SaveFailureKeepsTheDraftAndShowsAnError() - { - var storage = new TestLocalStorage - { - OnSetAsync = (_, _) => throw new JSException("Storage quota exceeded.") - }; - var cut = RenderHealthModelPage("Designer", storage, ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running)); - var graph = cut.FindComponent(); - var api = graph.Instance.Document.Entities.Single(e => e.AspireResourceName == "api"); - await cut.InvokeAsync(() => graph.Instance.MoveEntity(api.Name, 400, 300)); - - cut.FindAll("fluent-button").Single(b => b.TextContent.Trim() == Resources.HealthModel.HealthModelSave).Click(); - - cut.WaitForAssertion(() => - { - Assert.Equal(Resources.HealthModel.HealthModelSaveError, cut.Find("[role='alert']").TextContent.Trim()); - Assert.Equal(Resources.HealthModel.HealthModelUnsaved, cut.Find(".health-model-save-state").TextContent.Trim()); - Assert.NotEqual(api.CanvasPosition, cut.FindComponent().Instance.Document.Entities.Single(e => e.Name == api.Name).CanvasPosition); - }); - } - - [Fact] - public async Task SavePersistsThePortableDocumentAndDiscardRestoresIt() - { - HealthModelDocument? saved = null; - var storage = new TestLocalStorage - { - OnSetAsync = (_, value) => - { - saved = Assert.IsType(value); - return Task.CompletedTask; - } - }; - var cut = RenderHealthModelPage("Designer", storage, ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running)); - var graph = cut.FindComponent(); - var api = graph.Instance.Document.Entities.Single(e => e.AspireResourceName == "api"); - await cut.InvokeAsync(() => graph.Instance.MoveEntity(api.Name, 400, 300)); - cut.FindAll("fluent-button").Single(b => b.TextContent.Trim() == Resources.HealthModel.HealthModelSave).Click(); - cut.WaitForAssertion(() => Assert.NotNull(saved)); - var savedPosition = saved!.Entities.Single(e => e.Name == api.Name).CanvasPosition; - - await cut.InvokeAsync(() => graph.Instance.MoveEntity(api.Name, 800, 600)); - cut.FindAll("fluent-button").Single(b => b.TextContent.Trim() == Resources.HealthModel.HealthModelDiscard).Click(); - - cut.WaitForAssertion(() => - Assert.Equal(savedPosition, cut.FindComponent().Instance.Document.Entities.Single(e => e.Name == api.Name).CanvasPosition)); - } - - [Fact] - public async Task ResourcesDiscoveredAfterInitialLoadRecoverTheirSavedPositions() - { - var channel = Channel.CreateUnbounded>(); - var client = new TestDashboardClient(isEnabled: true, initialResources: [], resourceChannelProvider: () => channel); - var resource = ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running); - var model = AspireHealthModelBuilder.Build([resource]); - var document = HealthModelDocuments.Create(model, client.ApplicationName); - var savedPosition = new HealthModelCanvasPosition(640, 512); - document = document with - { - Entities = [.. document.Entities.Select(e => e.AspireResourceName == "api" ? e with { CanvasPosition = savedPosition } : e)] - }; - var storage = new TestLocalStorage - { - OnGetAsync = key => key.StartsWith("Aspire_HealthModel_v1_", StringComparison.Ordinal) - ? (true, document) : (false, null) - }; - var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); - HealthModelSetupHelpers.Setup(this, viewport, client, storage); - var cut = RenderComponent(parameters => parameters.AddCascadingValue(viewport)); - - await channel.Writer.WriteAsync([new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, resource)], Xunit.TestContext.Current.CancellationToken); - - cut.WaitForAssertion(() => Assert.Equal(savedPosition, - cut.FindComponent().Instance.Document.Entities.Single(e => e.AspireResourceName == "api").CanvasPosition)); - } - - private IRenderedComponent RenderHealthModelPage(string view, TestLocalStorage? storage, params ResourceViewModel[] resources) - { - var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); - var dashboardClient = new TestDashboardClient( - isEnabled: true, - initialResources: resources, - resourceChannelProvider: Channel.CreateUnbounded>); - - HealthModelSetupHelpers.Setup(this, viewport, dashboardClient, storage); - Services.GetRequiredService().NavigateTo(DashboardUrls.HealthModelUrl(view: view)); - - return RenderComponent(builder => - { - builder.AddCascadingValue(viewport); - }); - } -} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs deleted file mode 100644 index f9d0e644048..00000000000 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/HealthModelSetupHelpers.cs +++ /dev/null @@ -1,25 +0,0 @@ -// 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.Resize; -using Aspire.Dashboard.Model.BrowserStorage; -using Bunit; - -namespace Aspire.Dashboard.Components.Tests.Shared; - -internal static class HealthModelSetupHelpers -{ - public static void Setup(TestContext context, ViewportInformation viewport, IDashboardClient client, ILocalStorage? storage = null) - { - ResourceSetupHelpers.SetupResourcesPage(context, viewport, client, localStorage: storage); - FluentUISetupHelpers.SetupFluentList(context); - FluentUISetupHelpers.SetupFluentTextField(context); - - var module = context.JSInterop.SetupModule("/js/app-healthmodel.js"); - var graph = module.SetupModule("createHealthModelGraph", _ => true); - graph.SetupVoid("update", _ => true).SetVoidResult(); - graph.SetupVoid("fit", _ => true).SetVoidResult(); - graph.SetupVoid("zoomBy", _ => true).SetVoidResult(); - graph.SetupVoid("dispose", _ => true).SetVoidResult(); - } -} diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs deleted file mode 100644 index 82c9dbc44d0..00000000000 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/HealthModelTests.cs +++ /dev/null @@ -1,184 +0,0 @@ -// 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.Model.HealthModel; -using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; -using Aspire.TestUtilities; -using Microsoft.Playwright; -using Xunit; -using Strings = Aspire.Dashboard.Resources.HealthModel; - -namespace Aspire.Dashboard.Tests.Integration.Playwright; - -[RequiresFeature(TestFeature.Playwright)] -public class HealthModelTests(ResourceGraphTests.GraphDashboardServerFixture fixture) - : PlaywrightTestsBase(fixture) -{ - [Fact] - public async Task GraphAndEntitiesUseTheSameAppHostTopology() - { - await RunTestAsync(async page => - { - var model = CreateModel(); - await OpenAsync(page, model.Entities.Length); - var paths = await page.Locator(".health-model-edge").EvaluateAllAsync( - "elements => elements.map(e => e.dataset.parent + ':' + e.dataset.child)"); - Assert.Equal(model.Relationships.Select(r => r.ParentEntityName + ":" + r.ChildEntityName).Order(), paths.Order()); - - var root = page.Locator($".health-model-entity[data-entity='{model.Name}']"); - await Assertions.Expect(root).ToHaveAttributeAsync("data-health", "Unhealthy"); - await page.Locator("#Entities").ClickAsync(); - await Assertions.Expect(page.Locator(".health-model-entity-name")).ToHaveCountAsync(model.Entities.Length); - await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "database", Exact = true }).ClickAsync(); - await Assertions.Expect(page.Locator(".health-model-details-layout")).ToContainTextAsync(Strings.HealthModelSignalsSource); - await Assertions.Expect(page.Locator(".health-model-parent-list")).ToContainTextAsync("api"); - }); - } - - [Fact] - public async Task DesignerSavesExactPositionsAcrossReloadAndDefinitionExport() - { - await RunTestAsync(async page => - { - var model = CreateModel(); - await OpenAsync(page, model.Entities.Length); - await page.Locator("#Designer").ClickAsync(); - var entity = model.Entities.Single(e => e.AspireResourceName == "healthy"); - var card = page.Locator($".health-model-entity[data-entity='{entity.Name}']"); - var before = await card.GetAttributeAsync("transform"); - await DragAsync(page, card, 72, 36); - await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); - var moved = await card.GetAttributeAsync("transform"); - Assert.NotEqual(before, moved); - await SaveButton(page).ClickAsync(); - await Assertions.Expect(SaveButton(page)).ToBeDisabledAsync(); - await Assertions.Expect(page.GetByRole(AriaRole.Status)).ToContainTextAsync("Model saved"); - - await page.Locator("#Graph").ClickAsync(); - await page.ReloadAsync(); - await Assertions.Expect(card).ToHaveAttributeAsync("transform", moved!); - - var download = await page.RunAndWaitForDownloadAsync(() => - page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelExport, Exact = true }).ClickAsync()); - await using var stream = await download.CreateReadStreamAsync(); - Assert.NotNull(stream); - using var reader = new StreamReader(stream); - var json = await reader.ReadToEndAsync(); - var baseline = HealthModelDocuments.Create(model, "IntegrationTestApplication"); - var document = HealthModelDocuments.Deserialize(json, baseline); - var position = document.Entities.Single(e => e.Name == entity.Name).CanvasPosition; - Assert.Equal(FormattableString.Invariant($"translate({position.X},{position.Y})"), moved); - - await page.Locator("#Designer").ClickAsync(); - await DragAsync(page, card, -48, 56); - await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); - await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelDiscard, Exact = true }).ClickAsync(); - await Assertions.Expect(card).ToHaveAttributeAsync("transform", moved!); - }); - } - - [Fact] - public async Task ImportedPositionsAndImpactAreAppliedAsAnExplicitDraft() - { - await RunTestAsync(async page => - { - var model = CreateModel(); - await OpenAsync(page, model.Entities.Length); - var document = HealthModelDocuments.Create(model, "IntegrationTestApplication"); - var database = document.Entities.Single(e => e.AspireResourceName == "database"); - var imported = document with - { - Entities = [.. document.Entities.Select(e => e.Name == database.Name - ? e with { CanvasPosition = new(888, 688), Impact = EntityImpact.Limited } - : e)] - }; - await page.Locator("input[type='file']").SetInputFilesAsync(new FilePayload - { - Name = "aspire-healthmodel.json", - MimeType = "application/json", - Buffer = Encoding.UTF8.GetBytes(HealthModelDocuments.Serialize(imported)) - }); - - await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); - await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{database.Name}']")) - .ToHaveAttributeAsync("transform", "translate(888,688)"); - await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{model.Name}']")) - .ToHaveAttributeAsync("data-health", "Degraded"); - - await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelDiscard, Exact = true }).ClickAsync(); - await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{model.Name}']")) - .ToHaveAttributeAsync("data-health", "Unhealthy"); - }); - } - - [Fact] - public async Task DesignerEditsPropagationWithoutChangingTheAppHostTopology() - { - await RunTestAsync(async page => - { - var model = CreateModel(); - await OpenAsync(page, model.Entities.Length); - await page.Locator("#Designer").ClickAsync(); - var database = model.Entities.Single(e => e.AspireResourceName == "database"); - await page.Locator($".health-model-entity[data-entity='{database.Name}']").ClickAsync(); - await page.GetByRole(AriaRole.Combobox, new PageGetByRoleOptions { Name = Strings.HealthModelPropertyImpact, Exact = true }).ClickAsync(); - await page.GetByRole(AriaRole.Option, new PageGetByRoleOptions { Name = Strings.HealthModelImpactLimited, Exact = true }).ClickAsync(); - await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Strings.HealthModelApply, Exact = true }).ClickAsync(); - await Assertions.Expect(page.Locator($".health-model-entity[data-entity='{model.Name}']")) - .ToHaveAttributeAsync("data-health", "Degraded"); - await Assertions.Expect(page.Locator(".health-model-edge")).ToHaveCountAsync(model.Relationships.Length); - await Assertions.Expect(SaveButton(page)).ToBeEnabledAsync(); - }); - } - - [Fact] - public async Task InvalidImportDoesNotReplaceTheCurrentModel() - { - await RunTestAsync(async page => - { - var model = CreateModel(); - await OpenAsync(page, model.Entities.Length); - var before = await page.Locator(".health-model-entity").EvaluateAllAsync("nodes => nodes.map(n => n.getAttribute('transform'))"); - await page.Locator("input[type='file']").SetInputFilesAsync(new FilePayload - { - Name = "broken.json", - MimeType = "application/json", - Buffer = Encoding.UTF8.GetBytes("{\"schemaVersion\": 999}") - }); - await Assertions.Expect(page.GetByRole(AriaRole.Alert)).ToContainTextAsync(Strings.HealthModelImportError); - Assert.Equal(before, await page.Locator(".health-model-entity").EvaluateAllAsync("nodes => nodes.map(n => n.getAttribute('transform'))")); - }); - } - - private static HealthModelDefinition CreateModel() => AspireHealthModelBuilder.Build( - ResourceGraphTests.GraphDashboardServerFixture.CreateResources(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy)); - - private static ILocator SaveButton(IPage page) => page.GetByRole(AriaRole.Button, - new PageGetByRoleOptions { Name = Strings.HealthModelSave, Exact = true }); - - private static async Task OpenAsync(IPage page, int expectedEntities) - { - await page.SetViewportSizeAsync(1450, 1000); - await page.GotoAsync("/healthmodel"); - await Assertions.Expect(page.Locator(".health-model-entity")).ToHaveCountAsync(expectedEntities); - } - - private static async Task DragAsync(IPage page, ILocator card, float deltaX, float deltaY) - { - var bounds = await card.Locator(".health-model-card").BoundingBoxAsync(); - Assert.NotNull(bounds); - var x = bounds.X + bounds.Width / 2; - var y = bounds.Y + bounds.Height / 2; - await page.Mouse.MoveAsync(x, y); - await page.Mouse.DownAsync(); - try - { - await page.Mouse.MoveAsync(x + deltaX, y + deltaY, new MouseMoveOptions { Steps = 5 }); - } - finally - { - await page.Mouse.UpAsync(); - } - } -} diff --git a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs index dad53e90106..77d7162b2c6 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AspireHealthModelBuilderTests.cs @@ -3,7 +3,6 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.HealthModel; -using Aspire.Tests.Shared.DashboardModel; using Microsoft.Extensions.Diagnostics.HealthChecks; using Xunit; @@ -11,125 +10,6 @@ namespace Aspire.Dashboard.Tests.Model; public class AspireHealthModelBuilderTests { - [Fact] - public void Build_NoResources_StillProducesAppHostRoot() - { - var definition = AspireHealthModelBuilder.Build([]); - - Assert.Equal(AspireHealthModelBuilder.RootEntityName, Assert.Single(definition.Entities).Name); - Assert.Empty(definition.Relationships); - } - - [Fact] - public void Build_IndependentResources_AreChildrenOfTheAppHost() - { - var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); - var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running); - - var definition = AspireHealthModelBuilder.Build([project, container]); - - var projectEntityName = AspireHealthModelBuilder.GetEntityName(project); - var containerEntityName = AspireHealthModelBuilder.GetEntityName(container); - - Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, projectEntityName), definition.Relationships); - Assert.Contains(new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, containerEntityName), definition.Relationships); - Assert.All(definition.Entities, entity => Assert.Equal(EntityImpact.Standard, entity.Impact)); - } - - [Fact] - public void Build_ResourceWithoutRuntimeHealth_IsExcluded() - { - var parameter = ModelTestHelpers.CreateResource(resourceName: "secret", resourceType: KnownResourceTypes.Parameter, state: KnownResourceState.Running); - var connectionString = ModelTestHelpers.CreateResource(resourceName: "conn", resourceType: KnownResourceTypes.ConnectionString, state: KnownResourceState.Running); - - var definition = AspireHealthModelBuilder.Build([parameter, connectionString]); - - Assert.Equal(AspireHealthModelBuilder.RootEntityName, Assert.Single(definition.Entities).Name); - } - - [Fact] - public void Build_CustomResourceType_IsIncluded() - { - // Custom resource types can carry health checks, so they must appear in the model rather than being - // dropped because they are not a known type. - var custom = ModelTestHelpers.CreateResource(resourceName: "widget", resourceType: "Test Resource", state: KnownResourceState.Running); - - var definition = AspireHealthModelBuilder.Build([custom]); - - Assert.Contains( - new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.GetEntityName(custom)), - definition.Relationships); - } - - [Fact] - public void Build_ExternalService_IsIncluded() - { - var external = ModelTestHelpers.CreateResource(resourceName: "api-gateway", resourceType: KnownResourceTypes.ExternalService, state: KnownResourceState.Running); - - var definition = AspireHealthModelBuilder.Build([external]); - - Assert.Contains( - new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.GetEntityName(external)), - definition.Relationships); - } - - [Fact] - public void Build_HiddenResource_IsExcluded() - { - var hidden = ModelTestHelpers.CreateResource(resourceName: "hidden", resourceType: KnownResourceTypes.Container, hidden: true); - - var definition = AspireHealthModelBuilder.Build([hidden]); - - Assert.Equal(AspireHealthModelBuilder.RootEntityName, Assert.Single(definition.Entities).Name); - } - - [Fact] - public void Build_ResourceEntity_HasStateSignalAndOneSignalPerHealthReport() - { - var resource = ModelTestHelpers.CreateResource( - resourceName: "api", - resourceType: KnownResourceTypes.Project, - state: KnownResourceState.Running, - healthReports: - [ - new HealthReportViewModel("live", HealthStatus.Healthy, "All good", null), - new HealthReportViewModel("ready", HealthStatus.Degraded, "Warming up", null) - ]); - - var definition = AspireHealthModelBuilder.Build([resource]); - var entity = Assert.Single(definition.Entities, e => e.ResourceName == "api"); - - Assert.Collection(entity.Signals, - s => - { - Assert.Equal(AspireHealthModelBuilder.ResourceStateSignalName, s.Name); - Assert.Equal(HealthState.Healthy, s.State); - }, - s => - { - Assert.Equal("live", s.Name); - Assert.Equal(HealthState.Healthy, s.State); - Assert.Equal("All good", s.Description); - }, - s => - { - Assert.Equal("ready", s.Name); - Assert.Equal(HealthState.Degraded, s.State); - Assert.Equal("Warming up", s.Description); - }); - } - - [Fact] - public void Build_EntityName_IsStableAcrossAppHostRestarts() - { - // Resource names carry a random suffix that changes on every app host restart, so entity identity - // must come from the persistent key instead. - var first = ModelTestHelpers.CreateResource(resourceName: "api-abcdefgh", displayName: "api", resourceType: KnownResourceTypes.Project); - var second = ModelTestHelpers.CreateResource(resourceName: "api-ijklmnop", displayName: "api", resourceType: KnownResourceTypes.Project); - - Assert.Equal(AspireHealthModelBuilder.GetEntityName(first), AspireHealthModelBuilder.GetEntityName(second)); - } - [Theory] [InlineData(KnownResourceState.Running, HealthState.Healthy)] [InlineData(KnownResourceState.Finished, HealthState.Healthy)] @@ -159,85 +39,4 @@ public void MapHealthStatus_MapsHealthCheckResults(HealthStatus status, HealthSt { Assert.Equal(expected, AspireHealthModelBuilder.MapHealthStatus(status)); } - - [Fact] - public void BuildAndEvaluate_UnhealthyContainer_UsesStandardImpactUnlessConfigured() - { - var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); - var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.FailedToStart); - - var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project, container])); - - Assert.Equal(HealthState.Unhealthy, snapshot.State); - } - - [Fact] - public void Build_UsesTheAppHostDependencyChain_NotResourceTypeBuckets() - { - var api = ModelTestHelpers.CreateResource("api", state: KnownResourceState.Running, - relationships: [new("server", KnownRelationshipTypes.Reference)]); - var server = ModelTestHelpers.CreateResource("server", state: KnownResourceState.Running); - var database = ModelTestHelpers.CreateResource("database", state: KnownResourceState.Running, - relationships: [new("server", KnownRelationshipTypes.Parent)]); - var model = AspireHealthModelBuilder.Build([database, api, server]); - var expected = new[] - { - new HealthModelRelationship(AspireHealthModelBuilder.RootEntityName, AspireHealthModelBuilder.GetEntityName(api)), - new HealthModelRelationship(AspireHealthModelBuilder.GetEntityName(api), AspireHealthModelBuilder.GetEntityName(server)), - new HealthModelRelationship(AspireHealthModelBuilder.GetEntityName(server), AspireHealthModelBuilder.GetEntityName(database)) - }; - - Assert.Equal(expected.OrderBy(r => r.ParentEntityName).ThenBy(r => r.ChildEntityName), model.Relationships); - } - - [Theory] - [InlineData("api_v1")] - [InlineData("a.b")] - [InlineData("应用 service")] - public void EntityNames_AreStableAndAzureCompatible(string displayName) - { - var resource = ModelTestHelpers.CreateResource("runtime-suffix", displayName: displayName); - var name = AspireHealthModelBuilder.GetEntityName(resource); - Assert.Matches("^[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]$", name); - Assert.Equal(name, AspireHealthModelBuilder.GetEntityName(ModelTestHelpers.CreateResource("another-runtime-suffix", displayName: displayName))); - } - - [Fact] - public void EntityNames_DoNotCollideWhenDisplayNamesNormalizeToTheSameSlug() - { - var a = ModelTestHelpers.CreateResource(displayName: "api.v1"); - var b = ModelTestHelpers.CreateResource(displayName: "api_v1"); - Assert.NotEqual(AspireHealthModelBuilder.GetEntityName(a), AspireHealthModelBuilder.GetEntityName(b)); - } - - [Fact] - public void BuildAndEvaluate_UnhealthyProject_FailsApplication() - { - var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.FailedToStart); - - var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project])); - - Assert.Equal(HealthState.Unhealthy, snapshot.State); - } - - [Fact] - public void BuildAndEvaluate_AllResourcesRunning_IsHealthy() - { - var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Running); - var container = ModelTestHelpers.CreateResource(resourceName: "cache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running); - - var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project, container])); - - Assert.Equal(HealthState.Healthy, snapshot.State); - } - - [Fact] - public void BuildAndEvaluate_StartingResource_LeavesApplicationUnknownRatherThanUnhealthy() - { - var project = ModelTestHelpers.CreateResource(resourceName: "api", resourceType: KnownResourceTypes.Project, state: KnownResourceState.Starting); - - var snapshot = HealthModelEvaluator.Evaluate(AspireHealthModelBuilder.Build([project])); - - Assert.Equal(HealthState.Unknown, snapshot.State); - } } diff --git a/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs b/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs deleted file mode 100644 index c5017f26660..00000000000 --- a/tests/Aspire.Dashboard.Tests/Model/HealthModelDocumentTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -// 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.Json; -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Model.HealthModel; -using Aspire.Tests.Shared.DashboardModel; -using HealthModelPlayground; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using VerifyXunit; -using Xunit; - -namespace Aspire.Dashboard.Tests.Model; - -public class HealthModelDocumentTests -{ - [Fact] - public Task ExportedDocumentContainsOnlyDefinition() - { - var definition = CreateModel("api-random"); - var document = HealthModelDocuments.Create(definition, "SampleApp"); - - return Verifier.Verify(HealthModelDocuments.Serialize(document), "json").UseDirectory("Snapshots"); - } - - [Fact] - public void DocumentRoundTripPreservesExactPositionsAndPropagation() - { - var model = CreateModel("api-runtime-one"); - var original = HealthModelDocuments.Create(model, "SampleApp"); - var api = original.Entities.Single(e => e.AspireResourceName == "api"); - var changed = api with - { - CanvasPosition = new HealthModelCanvasPosition(-712.25, 318.5), - Impact = EntityImpact.Limited, - HealthObjective = 99.9, - Dependencies = new DependenciesAggregation - { - AggregationType = DependenciesAggregationType.MinHealthy, - Unit = AggregationUnit.Percentage, - UnhealthyThreshold = 40, - DegradedThreshold = 80 - } - }; - original = original with { Entities = [.. original.Entities.Select(e => e.Name == api.Name ? changed : e)] }; - - var imported = HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(original), original); - var restarted = HealthModelDocuments.Reconcile(imported, CreateModel("api-runtime-two")); - var rebound = restarted.Entities.Single(e => e.AspireResourceName == "api"); - - Assert.Equal(changed.CanvasPosition, rebound.CanvasPosition); - Assert.Equal(changed.Impact, rebound.Impact); - Assert.Equal(changed.HealthObjective, rebound.HealthObjective); - Assert.Equal(changed.Dependencies, rebound.Dependencies); - Assert.Equal(original.Relationships, restarted.Relationships); - Assert.Equal(changed.Name, rebound.Name); - } - - [Fact] - public void PublishedPlaygroundDefinitionMatchesDashboardIdentitiesAndLayout() - { - var resources = HealthModelScenario.Resources.Select(resource => - { - var relationships = resource.Dependencies.Select(dependency => new RelationshipViewModel(dependency, KnownRelationshipTypes.Reference)).ToList(); - if (resource.ParentName is { } parent) - { - relationships.Add(new(parent, KnownRelationshipTypes.Parent)); - } - return ModelTestHelpers.CreateResource(resource.Name, state: KnownResourceState.Running, - relationships: [.. relationships], replicaIndex: 1, - healthReports: [new(resource.HealthCheckName, resource.HealthStatus, null, null)]); - }); - var current = HealthModelDocuments.Create(AspireHealthModelBuilder.Build(resources), "HealthModelSandbox.AppHost"); - var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestData", "healthmodel-playground.json")); - var imported = HealthModelDocuments.Deserialize(json, current); - - Assert.Equal( - current.Entities.OrderBy(entity => entity.Name).Select(entity => (entity.Name, entity.CanvasPosition, entity.Impact, entity.Dependencies)), - imported.Entities.OrderBy(entity => entity.Name).Select(entity => (entity.Name, entity.CanvasPosition, entity.Impact, entity.Dependencies))); - } - - [Fact] - public void ImportRejectsAnotherApplicationOrChangedTopology() - { - var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); - var otherApp = current with { ApplicationName = "OtherApp" }; - var otherTopology = current with { Relationships = [] }; - - Assert.Throws(() => HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(otherApp), current)); - Assert.Throws(() => HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(otherTopology), current)); - } - - [Fact] - public void ImportRejectsUnknownPropertiesRatherThanDroppingData() - { - var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); - var json = HealthModelDocuments.Serialize(current).Replace("\"schemaVersion\": 1,", "\"schemaVersion\": 1, \"unsupportedSetting\": true,"); - - Assert.Throws(() => HealthModelDocuments.Deserialize(json, current)); - } - - [Fact] - public void ImportRequiresAnExplicitSchemaVersion() - { - var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); - var json = HealthModelDocuments.Serialize(current).Replace("\"schemaVersion\": 1,", string.Empty); - Assert.Throws(() => HealthModelDocuments.Deserialize(json, current)); - } - - [Fact] - public void ImportCannotReplaceTheLiveSignalBindings() - { - var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); - var edited = current with { Entities = [.. current.Entities.Select(e => e with { LocalSignals = [] })] }; - Assert.Throws(() => HealthModelDocuments.Deserialize(HealthModelDocuments.Serialize(edited), current)); - } - - [Theory] - [InlineData(double.NaN, 0)] - [InlineData(0, double.PositiveInfinity)] - [InlineData(1000001, 0)] - public void InvalidCoordinatesAreRejected(double x, double y) - { - var current = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); - var invalid = current with - { - Entities = [.. current.Entities.Select((e, i) => i == 0 ? e with { CanvasPosition = new(x, y) } : e)] - }; - - Assert.Throws(() => HealthModelDocuments.Validate(invalid, "SampleApp")); - } - - [Theory] - [InlineData(DependenciesAggregationType.MaxNotHealthy, 20, 10)] - [InlineData(DependenciesAggregationType.MinHealthy, 10, 20)] - public void ThresholdOrderingIsValidated(DependenciesAggregationType type, double degraded, double unhealthy) - { - var aggregation = new DependenciesAggregation - { - AggregationType = type, - Unit = AggregationUnit.Percentage, - DegradedThreshold = degraded, - UnhealthyThreshold = unhealthy - }; - Assert.Throws(() => HealthModelDocuments.ValidateAggregation(aggregation)); - } - - [Fact] - public void ArrangeIsIndependentOfResourceOrderAndDoesNotOverlap() - { - var model = CreateModel("api"); - var original = HealthModelLayout.Arrange(model); - var reordered = HealthModelLayout.Arrange(model with - { - Entities = [.. model.Entities.Reverse()], - Relationships = [.. model.Relationships.Reverse()] - }); - - Assert.Equal(original.OrderBy(p => p.Key), reordered.OrderBy(p => p.Key)); - foreach (var first in original) - { - foreach (var second in original.Where(pair => pair.Key != first.Key)) - { - Assert.True(Math.Abs(first.Value.X - second.Value.X) >= HealthModelLayout.CardWidth || - Math.Abs(first.Value.Y - second.Value.Y) >= HealthModelLayout.CardHeight); - } - } - } - - [Fact] - public void DropOnAnotherEntityFindsAFreePositionWithoutMovingTheOtherEntity() - { - var document = HealthModelDocuments.Create(CreateModel("api"), "SampleApp"); - var root = document.Entities[0]; - var api = document.Entities.Single(e => e.AspireResourceName == "api"); - - var position = HealthModelLayout.Place(document, api.Name, root.CanvasPosition); - - Assert.NotEqual(root.CanvasPosition, position); - Assert.True(Math.Abs(position.X - root.CanvasPosition.X) >= HealthModelLayout.CardWidth || - Math.Abs(position.Y - root.CanvasPosition.Y) >= HealthModelLayout.CardHeight); - Assert.Equal(root, document.Entities[0]); - } - - private static HealthModelDefinition CreateModel(string runtimeName) => AspireHealthModelBuilder.Build( - [ - ModelTestHelpers.CreateResource(runtimeName, displayName: "api", state: KnownResourceState.Running, - relationships: [new("database", KnownRelationshipTypes.Reference)], - environment: [new EnvironmentVariableViewModel("SECRET", "not-for-export", fromSpec: true)]), - ModelTestHelpers.CreateResource("database", displayName: "database", state: KnownResourceState.Running, - healthReports: [new("ready", HealthStatus.Unhealthy, "private measurement description", "private exception text")]) - ]); -} diff --git a/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs b/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs deleted file mode 100644 index bad6312bda4..00000000000 --- a/tests/Aspire.Dashboard.Tests/Model/HealthModelEvaluatorTests.cs +++ /dev/null @@ -1,361 +0,0 @@ -// 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.Immutable; -using Aspire.Dashboard.Model.HealthModel; -using Xunit; - -namespace Aspire.Dashboard.Tests.Model; - -public class HealthModelEvaluatorTests -{ - [Fact] - public void Evaluate_EntityWithNoSignalsOrChildren_IsUnknown() - { - var definition = CreateModel([Entity("root")], []); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(HealthState.Unknown, snapshot.State); - } - - [Theory] - [InlineData(HealthState.Healthy, HealthState.Healthy, HealthState.Healthy)] - [InlineData(HealthState.Healthy, HealthState.Degraded, HealthState.Degraded)] - [InlineData(HealthState.Degraded, HealthState.Unhealthy, HealthState.Unhealthy)] - [InlineData(HealthState.Unhealthy, HealthState.Unknown, HealthState.Unhealthy)] - public void Evaluate_EntitySignals_TakesWorstSignalState(HealthState first, HealthState second, HealthState expected) - { - var definition = CreateModel([Entity("root", signals: [Signal("a", first), Signal("b", second)])], []); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(expected, snapshot.State); - } - - [Fact] - public void Evaluate_UnknownChild_DoesNotDragParentDown() - { - // Unknown is the least severe state in Azure Monitor, so a child that has not reported yet must - // leave a healthy parent healthy rather than making the whole model look broken. - var definition = CreateModel( - [ - Entity("root"), - Entity("reporting", signals: [Signal("a", HealthState.Healthy)]), - Entity("silent") - ], - [("root", "reporting"), ("root", "silent")]); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(HealthState.Healthy, snapshot.State); - } - - [Fact] - public void Evaluate_WorstOfRollup_PropagatesWorstChild() - { - var definition = CreateModel( - [ - Entity("root"), - Entity("a", signals: [Signal("s", HealthState.Healthy)]), - Entity("b", signals: [Signal("s", HealthState.Unhealthy)]) - ], - [("root", "a"), ("root", "b")]); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(HealthState.Unhealthy, snapshot.State); - } - - [Fact] - public void Evaluate_EntityCombinesOwnSignalsWithDependencies() - { - // The parent's own signal is degraded while its child is unhealthy. The final state is the worst - // of the two, not just whichever was evaluated last. - var definition = CreateModel( - [ - Entity("root", signals: [Signal("s", HealthState.Degraded)]), - Entity("child", signals: [Signal("s", HealthState.Unhealthy)]) - ], - [("root", "child")]); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(HealthState.Unhealthy, snapshot.State); - Assert.Equal(HealthState.Degraded, snapshot.Root!.SignalsState); - Assert.Equal(HealthState.Unhealthy, snapshot.Root.DependenciesState); - } - - [Theory] - [InlineData(EntityImpact.Standard, HealthState.Unhealthy)] - [InlineData(EntityImpact.Limited, HealthState.Degraded)] - [InlineData(EntityImpact.Suppressed, HealthState.Healthy)] - public void Evaluate_ChildImpact_RewritesStateSeenByParent(EntityImpact impact, HealthState expected) - { - var definition = CreateModel( - [ - Entity("root"), - Entity("child", impact: impact, signals: [Signal("s", HealthState.Unhealthy)]) - ], - [("root", "child")]); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(expected, snapshot.State); - - // The child itself still reports its true state. Only what the parent sees is rewritten. - Assert.Equal(HealthState.Unhealthy, snapshot.Root!.Children.Single().State); - } - - [Fact] - public void Evaluate_LimitedImpact_SwallowsDegradedEntirely() - { - var definition = CreateModel( - [ - Entity("root"), - Entity("child", impact: EntityImpact.Limited, signals: [Signal("s", HealthState.Degraded)]) - ], - [("root", "child")]); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(HealthState.Healthy, snapshot.State); - } - - [Theory] - // Three children, unhealthy once two or more are broken, degraded once one is broken. - [InlineData(0, HealthState.Healthy)] - [InlineData(1, HealthState.Degraded)] - [InlineData(2, HealthState.Unhealthy)] - [InlineData(3, HealthState.Unhealthy)] - public void Evaluate_MaxNotHealthyRollup_BreachesWhenNotHealthyCountReachesThreshold(int unhealthyCount, HealthState expected) - { - var entities = new List - { - Entity("root", dependencies: new DependenciesAggregation - { - AggregationType = DependenciesAggregationType.MaxNotHealthy, - DegradedThreshold = 1, - UnhealthyThreshold = 2 - }) - }; - - var relationships = new List<(string, string)>(); - for (var i = 0; i < 3; i++) - { - var state = i < unhealthyCount ? HealthState.Unhealthy : HealthState.Healthy; - entities.Add(Entity($"child{i}", signals: [Signal("s", state)])); - relationships.Add(("root", $"child{i}")); - } - - var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], relationships)); - - Assert.Equal(expected, snapshot.State); - } - - [Theory] - // Four children where at least three must be healthy. Degrades at three, fails at two. - [InlineData(4, HealthState.Healthy)] - [InlineData(3, HealthState.Degraded)] - [InlineData(2, HealthState.Unhealthy)] - public void Evaluate_MinHealthyRollup_BreachesWhenHealthyCountFallsToThreshold(int healthyCount, HealthState expected) - { - var entities = new List - { - Entity("root", dependencies: new DependenciesAggregation - { - AggregationType = DependenciesAggregationType.MinHealthy, - DegradedThreshold = 3, - UnhealthyThreshold = 2 - }) - }; - - var relationships = new List<(string, string)>(); - for (var i = 0; i < 4; i++) - { - var state = i < healthyCount ? HealthState.Healthy : HealthState.Unhealthy; - entities.Add(Entity($"child{i}", signals: [Signal("s", state)])); - relationships.Add(("root", $"child{i}")); - } - - var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], relationships)); - - Assert.Equal(expected, snapshot.State); - } - - [Fact] - public void Evaluate_PercentageUnit_UsesShareOfChildren() - { - // Two of four children unhealthy is 50%, which reaches the 50% unhealthy threshold. - var entities = new List - { - Entity("root", dependencies: new DependenciesAggregation - { - AggregationType = DependenciesAggregationType.MaxNotHealthy, - UnhealthyThreshold = 50, - Unit = AggregationUnit.Percentage - }) - }; - - var relationships = new List<(string, string)>(); - for (var i = 0; i < 4; i++) - { - var state = i < 2 ? HealthState.Unhealthy : HealthState.Healthy; - entities.Add(Entity($"child{i}", signals: [Signal("s", state)])); - relationships.Add(("root", $"child{i}")); - } - - var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], relationships)); - - Assert.Equal(HealthState.Unhealthy, snapshot.State); - } - - [Fact] - public void Evaluate_IgnoreUnknown_ExcludesUnknownChildrenFromThreshold() - { - // One unhealthy and one unknown child. With unknown ignored the denominator is one, so the single - // unhealthy child is 100% and breaches. Without ignoring it the share would only be 50%. - var entities = new List - { - Entity("root", dependencies: new DependenciesAggregation - { - AggregationType = DependenciesAggregationType.MaxNotHealthy, - UnhealthyThreshold = 100, - Unit = AggregationUnit.Percentage, - IgnoreUnknown = true - }), - Entity("broken", signals: [Signal("s", HealthState.Unhealthy)]), - Entity("silent") - }; - - var snapshot = HealthModelEvaluator.Evaluate( - CreateModel([.. entities], [("root", "broken"), ("root", "silent")])); - - Assert.Equal(HealthState.Unhealthy, snapshot.State); - } - - [Fact] - public void Evaluate_ThresholdRollupWithOnlyUnknownChildren_IsUnknown() - { - var entities = new List - { - Entity("root", dependencies: new DependenciesAggregation - { - AggregationType = DependenciesAggregationType.MaxNotHealthy, - UnhealthyThreshold = 1 - }), - Entity("silent") - }; - - var snapshot = HealthModelEvaluator.Evaluate(CreateModel([.. entities], [("root", "silent")])); - - Assert.Equal(HealthState.Unknown, snapshot.State); - } - - [Fact] - public void Evaluate_FlattensNodesInDepthFirstOrderWithDepth() - { - var definition = CreateModel( - [Entity("root"), Entity("group"), Entity("leaf"), Entity("sibling")], - [("root", "group"), ("group", "leaf"), ("root", "sibling")]); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Collection(snapshot.AllNodes, - n => { Assert.Equal("root", n.Name); Assert.Equal(0, n.Depth); }, - n => { Assert.Equal("group", n.Name); Assert.Equal(1, n.Depth); }, - n => { Assert.Equal("leaf", n.Name); Assert.Equal(2, n.Depth); }, - n => { Assert.Equal("sibling", n.Name); Assert.Equal(1, n.Depth); }); - } - - [Fact] - public void Evaluate_CyclicRelationships_ReportsInvalidHierarchy() - { - var definition = CreateModel( - [Entity("root"), Entity("a"), Entity("b")], - [("root", "a"), ("a", "b"), ("b", "a")]); - - Assert.Throws(() => HealthModelEvaluator.Evaluate(definition)); - } - - [Fact] - public void Evaluate_SharedDependency_ProducesOneNodeAndKeepsBothRelationships() - { - var definition = CreateModel( - [Entity("root"), Entity("a"), Entity("b"), Entity("shared", signals: [Signal("s", HealthState.Degraded)])], - [("root", "a"), ("root", "b"), ("a", "shared"), ("b", "shared")]); - - var snapshot = HealthModelEvaluator.Evaluate(definition); - - Assert.Equal(4, snapshot.AllNodes.Length); - Assert.Equal(HealthState.Degraded, snapshot.State); - var shared = Assert.Single(snapshot.AllNodes, n => n.Name == "shared"); - Assert.Same(shared, Assert.Single(snapshot.AllNodes.Single(n => n.Name == "a").Children)); - Assert.Same(shared, Assert.Single(snapshot.AllNodes.Single(n => n.Name == "b").Children)); - } - - [Fact] - public void Evaluate_NoEntities_ReturnsEmptySnapshot() - { - var snapshot = HealthModelEvaluator.Evaluate(new HealthModelDefinition { Name = "root" }); - - Assert.Null(snapshot.Root); - Assert.Empty(snapshot.AllNodes); - Assert.Equal(HealthState.Unknown, snapshot.State); - } - - [Fact] - public void EvaluationRule_UnhealthyWins_WhenBothRulesMatch() - { - var rule = new EvaluationRule( - UnhealthyRule: new ThresholdRule(SignalOperator.LessThan, 99), - DegradedRule: new ThresholdRule(SignalOperator.LessThan, 100)); - - Assert.Equal(HealthState.Healthy, rule.Evaluate(100)); - Assert.Equal(HealthState.Degraded, rule.Evaluate(99.5)); - Assert.Equal(HealthState.Unhealthy, rule.Evaluate(98)); - } - - [Fact] - public void Signal_WithEvaluationRules_PrefersObservedValueOverReportedState() - { - var signal = new HealthModelSignal - { - Name = "availability", - Kind = SignalKind.AzureResourceMetric, - ObservedValue = 50, - ReportedState = HealthState.Healthy, - EvaluationRules = new EvaluationRule(new ThresholdRule(SignalOperator.LessThan, 99)) - }; - - Assert.Equal(HealthState.Unhealthy, signal.State); - } - - private static HealthModelDefinition CreateModel(ImmutableArray entities, IEnumerable<(string Parent, string Child)> relationships) - { - return new HealthModelDefinition - { - Name = "root", - Entities = entities, - Relationships = [.. relationships.Select(r => new HealthModelRelationship(r.Parent, r.Child))] - }; - } - - private static HealthModelEntity Entity( - string name, - EntityImpact impact = EntityImpact.Standard, - DependenciesAggregation? dependencies = null, - ImmutableArray? signals = null) - { - return new HealthModelEntity - { - Name = name, - Impact = impact, - Dependencies = dependencies ?? DependenciesAggregation.WorstOf, - Signals = signals ?? [] - }; - } - - private static HealthModelSignal Signal(string name, HealthState state) - => new() { Name = name, ReportedState = state }; -} diff --git a/tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json b/tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json deleted file mode 100644 index 6ce5fc4ac69..00000000000 --- a/tests/Aspire.Dashboard.Tests/Model/Snapshots/HealthModelDocumentTests.ExportedDocumentContainsOnlyDefinition.verified.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "schemaVersion": 1, - "name": "aspire-app-health", - "applicationName": "SampleApp", - "entities": [ - { - "name": "aspire-app-health", - "displayName": "AppHost", - "aspireResourceName": null, - "replicaIndex": null, - "canvasPosition": { - "x": 0, - "y": 0 - }, - "impact": "Standard", - "healthObjective": null, - "dependencies": { - "aggregationType": "WorstOf", - "degradedThreshold": null, - "unhealthyThreshold": null, - "unit": "Absolute", - "ignoreUnknown": true - }, - "localSignals": [] - }, - { - "name": "resource-api-4f52125c3d5b162d", - "displayName": "api", - "aspireResourceName": "api", - "replicaIndex": 0, - "canvasPosition": { - "x": 0, - "y": 180 - }, - "impact": "Standard", - "healthObjective": null, - "dependencies": { - "aggregationType": "WorstOf", - "degradedThreshold": null, - "unhealthyThreshold": null, - "unit": "Absolute", - "ignoreUnknown": true - }, - "localSignals": [ - { - "name": "resource-state", - "kind": "External" - } - ] - }, - { - "name": "resource-database-cbf60de488b72f16", - "displayName": "database", - "aspireResourceName": "database", - "replicaIndex": 0, - "canvasPosition": { - "x": 0, - "y": 360 - }, - "impact": "Standard", - "healthObjective": null, - "dependencies": { - "aggregationType": "WorstOf", - "degradedThreshold": null, - "unhealthyThreshold": null, - "unit": "Absolute", - "ignoreUnknown": true - }, - "localSignals": [ - { - "name": "resource-state", - "kind": "External" - }, - { - "name": "ready", - "kind": "External" - } - ] - } - ], - "relationships": [ - { - "parentEntityName": "aspire-app-health", - "childEntityName": "resource-api-4f52125c3d5b162d" - }, - { - "parentEntityName": "resource-api-4f52125c3d5b162d", - "childEntityName": "resource-database-cbf60de488b72f16" - } - ] -} \ No newline at end of file