Skip to content

Commit 73459e9

Browse files
authored
Dashboard gracefully handles duplicate property names (#8373)
1 parent 01da7a5 commit 73459e9

File tree

3 files changed

+71
-27
lines changed

3 files changed

+71
-27
lines changed

src/Aspire.Dashboard/ResourceService/DashboardClient.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ async Task WatchResourcesAsync()
341341
foreach (var resource in response.InitialData.Resources)
342342
{
343343
// Add to map.
344-
var viewModel = resource.ToViewModel(_timeProvider, _knownPropertyLookup);
344+
var viewModel = resource.ToViewModel(_timeProvider, _knownPropertyLookup, _logger);
345345
_resourceByName[resource.Name] = viewModel;
346346

347347
// Send this update to any subscribers too.
@@ -361,7 +361,7 @@ async Task WatchResourcesAsync()
361361
if (change.KindCase == WatchResourcesChange.KindOneofCase.Upsert)
362362
{
363363
// Upsert (i.e. add or replace)
364-
var viewModel = change.Upsert.ToViewModel(_timeProvider, _knownPropertyLookup);
364+
var viewModel = change.Upsert.ToViewModel(_timeProvider, _knownPropertyLookup, _logger);
365365
_resourceByName[change.Upsert.Name] = viewModel;
366366
changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel));
367367
}
@@ -584,7 +584,7 @@ internal void SetInitialDataReceived(IList<Resource>? initialData = null)
584584
{
585585
foreach (var data in initialData)
586586
{
587-
_resourceByName[data.Name] = data.ToViewModel(_timeProvider, _knownPropertyLookup);
587+
_resourceByName[data.Name] = data.ToViewModel(_timeProvider, _knownPropertyLookup, _logger);
588588
}
589589
}
590590
}

src/Aspire.Dashboard/ResourceService/Partials.cs

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using CommandsResources = Aspire.Dashboard.Resources.Commands;
1010
using Aspire.Dashboard.Resources;
1111
using Aspire.Hosting;
12+
using Google.Protobuf.Collections;
1213

1314
namespace Aspire.ResourceService.Proto.V1;
1415

@@ -17,7 +18,7 @@ partial class Resource
1718
/// <summary>
1819
/// Converts this gRPC message object to a view model for use in the dashboard UI.
1920
/// </summary>
20-
public ResourceViewModel ToViewModel(BrowserTimeProvider timeProvider, IKnownPropertyLookup knownPropertyLookup)
21+
public ResourceViewModel ToViewModel(BrowserTimeProvider timeProvider, IKnownPropertyLookup knownPropertyLookup, ILogger logger)
2122
{
2223
try
2324
{
@@ -30,21 +31,7 @@ public ResourceViewModel ToViewModel(BrowserTimeProvider timeProvider, IKnownPro
3031
CreationTimeStamp = ValidateNotNull(CreatedAt).ToDateTime(),
3132
StartTimeStamp = StartedAt?.ToDateTime(),
3233
StopTimeStamp = StoppedAt?.ToDateTime(),
33-
Properties = Properties.ToImmutableDictionary(
34-
keyComparer: StringComparers.ResourcePropertyName,
35-
keySelector: property => ValidateNotNull(property.Name),
36-
elementSelector: property =>
37-
{
38-
var (priority, knownProperty) = knownPropertyLookup.FindProperty(ResourceType, property.Name);
39-
40-
return new ResourcePropertyViewModel(
41-
name: ValidateNotNull(property.Name),
42-
value: ValidateNotNull(property.Value),
43-
isValueSensitive: property.IsSensitive,
44-
knownProperty: knownProperty,
45-
priority: priority,
46-
timeProvider: timeProvider);
47-
}),
34+
Properties = CreatePropertyViewModels(Properties, timeProvider, knownPropertyLookup, logger),
4835
Environment = GetEnvironment(),
4936
Urls = GetUrls(),
5037
Volumes = GetVolumes(),
@@ -160,16 +147,42 @@ static FluentUIIconVariant MapIconVariant(IconVariant iconVariant)
160147
};
161148
}
162149
}
150+
}
151+
152+
private ImmutableDictionary<string, ResourcePropertyViewModel> CreatePropertyViewModels(RepeatedField<ResourceProperty> properties, BrowserTimeProvider timeProvider, IKnownPropertyLookup knownPropertyLookup, ILogger logger)
153+
{
154+
var builder = ImmutableDictionary.CreateBuilder<string, ResourcePropertyViewModel>(StringComparers.ResourcePropertyName);
163155

164-
T ValidateNotNull<T>(T value, [CallerArgumentExpression(nameof(value))] string? expression = null) where T : class
156+
foreach (var property in properties)
165157
{
166-
if (value is null)
158+
var (priority, knownProperty) = knownPropertyLookup.FindProperty(ResourceType, property.Name);
159+
var propertyViewModel = new ResourcePropertyViewModel(
160+
name: ValidateNotNull(property.Name),
161+
value: ValidateNotNull(property.Value),
162+
isValueSensitive: property.IsSensitive,
163+
knownProperty: knownProperty,
164+
priority: priority,
165+
timeProvider: timeProvider);
166+
167+
if (builder.ContainsKey(propertyViewModel.Name))
167168
{
168-
throw new InvalidOperationException($"Message field '{expression}' on resource with name '{Name}' cannot be null.");
169+
logger.LogWarning("Duplicate property '{PropertyName}' found in resource '{ResourceName}'.", propertyViewModel.Name, Name);
169170
}
170171

171-
return value;
172+
builder[propertyViewModel.Name] = propertyViewModel;
173+
}
174+
175+
return builder.ToImmutable();
176+
}
177+
178+
private T ValidateNotNull<T>(T value, [CallerArgumentExpression(nameof(value))] string? expression = null) where T : class
179+
{
180+
if (value is null)
181+
{
182+
throw new InvalidOperationException($"Message field '{expression}' on resource with name '{Name}' cannot be null.");
172183
}
184+
185+
return value;
173186
}
174187
}
175188

tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,48 @@ public void ToViewModel_EmptyEnvVarName_Success()
4848
};
4949

5050
// Act
51-
var vm = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup());
51+
var vm = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(), NullLogger.Instance);
5252

5353
// Assert
54-
Assert.Collection(resource.Environment,
54+
Assert.Collection(vm.Environment,
5555
e =>
5656
{
5757
Assert.Empty(e.Name);
5858
Assert.Equal("Value!", e.Value);
5959
});
6060
}
6161

62+
[Fact]
63+
public void ToViewModel_DuplicatePropertyNames_Success()
64+
{
65+
// Arrange
66+
var resource = new Resource
67+
{
68+
Name = "TestName-abc",
69+
DisplayName = "TestName",
70+
CreatedAt = Timestamp.FromDateTime(s_dateTime),
71+
Properties =
72+
{
73+
new ResourceProperty { Name = "test", Value = Value.ForString("one!") },
74+
new ResourceProperty { Name = "test", Value = Value.ForString("two!") }
75+
}
76+
};
77+
78+
// Act
79+
var vm = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(), NullLogger.Instance);
80+
81+
// Assert
82+
Assert.Collection(vm.Properties,
83+
e =>
84+
{
85+
var (key, vm) = (e.Key, e.Value);
86+
87+
Assert.Equal("test", key);
88+
Assert.Equal("test", vm.Name);
89+
Assert.Equal("two!", vm.Value.StringValue);
90+
});
91+
}
92+
6293
[Fact]
6394
public void ToViewModel_MissingRequiredData_FailWithFriendlyError()
6495
{
@@ -69,7 +100,7 @@ public void ToViewModel_MissingRequiredData_FailWithFriendlyError()
69100
};
70101

71102
// Act
72-
var ex = Assert.Throws<InvalidOperationException>(() => resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup()));
103+
var ex = Assert.Throws<InvalidOperationException>(() => resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(), NullLogger.Instance));
73104

74105
// Assert
75106
Assert.Equal(@"Error converting resource ""TestName-abc"" to ResourceViewModel.", ex.Message);
@@ -95,7 +126,7 @@ public void ToViewModel_CopiesProperties()
95126
var kp = new KnownProperty("foo", "bar");
96127

97128
// Act
98-
var viewModel = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(123, kp));
129+
var viewModel = resource.ToViewModel(s_timeProvider, new MockKnownPropertyLookup(123, kp), NullLogger.Instance);
99130

100131
// Assert
101132
Assert.Collection(

0 commit comments

Comments
 (0)