Skip to content

Commit b5ede2a

Browse files
authored
Merge pull request #60 from delegateas/features/webresource-analyzer
WebResource analyzer
2 parents 976c595 + 0249963 commit b5ede2a

24 files changed

Lines changed: 913 additions & 397 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Generator.DTO.Warnings;
2+
3+
public record AttributeWarning(string Message) : SolutionWarning(
4+
SolutionWarningType.Attribute,
5+
Message
6+
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Generator.DTO.Warnings;
2+
3+
public enum SolutionWarningType
4+
{
5+
Attribute,
6+
}
7+
8+
public record SolutionWarning(
9+
SolutionWarningType Type,
10+
string Message
11+
);

Generator/DTO/WebResource.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Microsoft.Xrm.Sdk;
2+
3+
namespace Generator.DTO;
4+
5+
public record WebResource(
6+
string Id,
7+
string Name,
8+
string Content,
9+
OptionSetValue WebResourceType,
10+
string? Description = null) : Analyzeable();

Generator/DataverseService.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
using Azure.Identity;
33
using Generator.DTO;
44
using Generator.DTO.Attributes;
5+
using Generator.DTO.Warnings;
56
using Generator.Queries;
67
using Generator.Services;
78
using Generator.Services.Plugins;
9+
using Generator.Services.WebResources;
810
using Microsoft.Crm.Sdk.Messages;
911
using Microsoft.Extensions.Caching.Memory;
1012
using Microsoft.Extensions.Configuration;
@@ -14,6 +16,7 @@
1416
using Microsoft.Xrm.Sdk.Metadata;
1517
using Microsoft.Xrm.Sdk.Query;
1618
using System.Collections.Concurrent;
19+
using System.Diagnostics;
1720
using System.Reflection;
1821
using Attribute = Generator.DTO.Attributes.Attribute;
1922

@@ -27,6 +30,7 @@ internal class DataverseService
2730

2831
private readonly PluginAnalyzer pluginAnalyzer;
2932
private readonly PowerAutomateFlowAnalyzer flowAnalyzer;
33+
private readonly WebResourceAnalyzer webResourceAnalyzer;
3034

3135
public DataverseService(IConfiguration configuration, ILogger<DataverseService> logger)
3236
{
@@ -47,10 +51,12 @@ public DataverseService(IConfiguration configuration, ILogger<DataverseService>
4751

4852
pluginAnalyzer = new PluginAnalyzer(client);
4953
flowAnalyzer = new PowerAutomateFlowAnalyzer(client);
54+
webResourceAnalyzer = new WebResourceAnalyzer(client, configuration);
5055
}
5156

52-
public async Task<IEnumerable<Record>> GetFilteredMetadata()
57+
public async Task<(IEnumerable<Record>, IEnumerable<SolutionWarning>)> GetFilteredMetadata()
5358
{
59+
var warnings = new List<SolutionWarning>(); // used to collect warnings for the insights dashboard
5460
var (publisherPrefix, solutionIds) = await GetSolutionIds();
5561
var solutionComponents = await GetSolutionComponents(solutionIds); // (id, type, rootcomponentbehavior)
5662

@@ -96,15 +102,32 @@ public async Task<IEnumerable<Record>> GetFilteredMetadata()
96102
// Processes analysis
97103
var attributeUsages = new Dictionary<string, Dictionary<string, List<AttributeUsage>>>();
98104
// Plugins
105+
var pluginStopWatch = new Stopwatch();
106+
pluginStopWatch.Start();
99107
var pluginCollection = await client.GetSDKMessageProcessingStepsAsync(solutionIds);
100108
logger.LogInformation($"There are {pluginCollection.Count()} plugin sdk steps in the environment.");
101109
foreach (var plugin in pluginCollection)
102110
await pluginAnalyzer.AnalyzeComponentAsync(plugin, attributeUsages);
111+
pluginStopWatch.Stop();
112+
logger.LogInformation($"Plugin analysis took {pluginStopWatch.ElapsedMilliseconds} ms.");
103113
// Flows
114+
var flowStopWatch = new Stopwatch();
115+
flowStopWatch.Start();
104116
var flowCollection = await client.GetPowerAutomateFlowsAsync(solutionIds);
105117
logger.LogInformation($"There are {flowCollection.Count()} Power Automate flows in the environment.");
106118
foreach (var flow in flowCollection)
107119
await flowAnalyzer.AnalyzeComponentAsync(flow, attributeUsages);
120+
flowStopWatch.Stop();
121+
logger.LogInformation($"Power Automate flow analysis took {flowStopWatch.ElapsedMilliseconds} ms.");
122+
// WebResources
123+
var resourceStopWatch = new Stopwatch();
124+
resourceStopWatch.Start();
125+
var webresourceCollection = await client.GetWebResourcesAsync(solutionIds);
126+
logger.LogInformation($"There are {webresourceCollection.Count()} WebResources in the environment.");
127+
foreach (var resource in webresourceCollection)
128+
await webResourceAnalyzer.AnalyzeComponentAsync(resource, attributeUsages);
129+
resourceStopWatch.Stop();
130+
logger.LogInformation($"WebResource analysis took {resourceStopWatch.ElapsedMilliseconds} ms.");
108131

109132
var records =
110133
entitiesInSolutionMetadata
@@ -123,8 +146,17 @@ public async Task<IEnumerable<Record>> GetFilteredMetadata()
123146
.Where(x => x.EntityMetadata.DisplayName.UserLocalizedLabel?.Label != null)
124147
.ToList();
125148

149+
// Warn about attributes that were used in processes, but the entity could not be resolved from e.g. JavaScript file name or similar
150+
var hash = entitiesInSolutionMetadata.SelectMany<EntityMetadata, string>(r => [r.LogicalCollectionName?.ToLower() ?? "", r.LogicalName.ToLower()]).ToHashSet();
151+
warnings.AddRange(attributeUsages.Keys
152+
.Where(k => !hash.Contains(k.ToLower()))
153+
.SelectMany(entityKey => attributeUsages.GetValueOrDefault(entityKey)!
154+
.SelectMany(attributeDict => attributeDict.Value
155+
.Select(usage =>
156+
new AttributeWarning($"{attributeDict.Key} was used inside a {usage.ComponentType} component [{usage.Name}]. However, the entity {entityKey} could not be resolved in the provided solutions.")))));
126157

127-
return records
158+
159+
return (records
128160
.Select(x =>
129161
{
130162
logicalNameToSecurityRoles.TryGetValue(x.EntityMetadata.LogicalName, out var securityRoles);
@@ -142,7 +174,7 @@ public async Task<IEnumerable<Record>> GetFilteredMetadata()
142174
entityIconMap,
143175
attributeUsages,
144176
configuration);
145-
});
177+
}), warnings);
146178
}
147179

148180
private static Record MakeRecord(

Generator/Generator.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
1414
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.6" />
1515
<PackageReference Include="Microsoft.PowerPlatform.Dataverse.Client" Version="1.2.2" />
16+
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.7" />
1617
</ItemGroup>
1718

1819
<ItemGroup>

Generator/Program.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
var logger = loggerFactory.CreateLogger<DataverseService>();
1919

2020
var dataverseService = new DataverseService(configuration, logger);
21-
var entities = (await dataverseService.GetFilteredMetadata()).ToList();
21+
var (entities, warnings) = await dataverseService.GetFilteredMetadata();
2222

23-
var websiteBuilder = new WebsiteBuilder(configuration, entities);
23+
var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings);
2424
websiteBuilder.AddData();
2525

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using Generator.DTO;
2+
using Microsoft.PowerPlatform.Dataverse.Client;
3+
using Microsoft.Xrm.Sdk;
4+
using Microsoft.Xrm.Sdk.Query;
5+
6+
namespace Generator.Queries;
7+
8+
public static class WebResourceQueries
9+
{
10+
11+
public static async Task<IEnumerable<WebResource>> GetWebResourcesAsync(this ServiceClient service, List<Guid>? solutionIds = null)
12+
{
13+
var query = new QueryExpression("solutioncomponent")
14+
{
15+
ColumnSet = new ColumnSet("objectid"),
16+
Criteria = new FilterExpression(LogicalOperator.And)
17+
{
18+
Conditions =
19+
{
20+
new ConditionExpression("solutionid", ConditionOperator.In, solutionIds),
21+
new ConditionExpression("componenttype", ConditionOperator.Equal, 61) // 61 = Web Resource
22+
}
23+
},
24+
LinkEntities =
25+
{
26+
new LinkEntity(
27+
"solutioncomponent",
28+
"webresource",
29+
"objectid",
30+
"webresourceid",
31+
JoinOperator.Inner)
32+
{
33+
Columns = new ColumnSet("webresourceid", "name", "content", "webresourcetype", "description"),
34+
EntityAlias = "webresource",
35+
LinkCriteria = new FilterExpression
36+
{
37+
Conditions =
38+
{
39+
new ConditionExpression("webresourcetype", ConditionOperator.Equal, 3) // JS Resources
40+
}
41+
}
42+
}
43+
}
44+
};
45+
46+
var results = (await service.RetrieveMultipleAsync(query)).Entities;
47+
48+
var webResources = results.Select(e =>
49+
{
50+
var content = "";
51+
var contentValue = e.GetAttributeValue<AliasedValue>("webresource.content")?.Value;
52+
var webresourceId = e.GetAttributeValue<AliasedValue>("webresource.webresourceid").Value?.ToString() ?? "";
53+
var webresourceName = e.GetAttributeValue<AliasedValue>("webresource.name").Value?.ToString();
54+
if (contentValue != null)
55+
{
56+
// Content is base64 encoded, decode it
57+
var base64Content = contentValue.ToString();
58+
if (!string.IsNullOrEmpty(base64Content))
59+
{
60+
try
61+
{
62+
var bytes = Convert.FromBase64String(base64Content);
63+
content = System.Text.Encoding.UTF8.GetString(bytes);
64+
}
65+
catch
66+
{
67+
// If decoding fails, keep the base64 content
68+
content = base64Content;
69+
}
70+
}
71+
}
72+
73+
return new WebResource(
74+
webresourceId,
75+
webresourceName,
76+
content,
77+
(OptionSetValue)e.GetAttributeValue<AliasedValue>("webresource.webresourcetype").Value,
78+
e.GetAttributeValue<AliasedValue>("webresource.description")?.Value?.ToString()
79+
);
80+
});
81+
82+
return webResources;
83+
}
84+
}

Generator/Services/ComponentAnalyzerBase.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ public abstract class BaseComponentAnalyzer<T>(ServiceClient service) : ICompone
1111
public abstract ComponentType SupportedType { get; }
1212
public abstract Task AnalyzeComponentAsync(T component, Dictionary<string, Dictionary<string, List<AttributeUsage>>> attributeUsages);
1313

14+
protected void AddAttributeUsage(Dictionary<string, Dictionary<string, List<AttributeUsage>>> attributeUsages,
15+
string entityName, string attributeName, AttributeUsage usage)
16+
{
17+
if (!attributeUsages.ContainsKey(entityName))
18+
{
19+
attributeUsages[entityName] = new Dictionary<string, List<AttributeUsage>>();
20+
}
21+
22+
if (!attributeUsages[entityName].ContainsKey(attributeName))
23+
{
24+
attributeUsages[entityName][attributeName] = new List<AttributeUsage>();
25+
}
26+
27+
attributeUsages[entityName][attributeName].Add(usage);
28+
}
29+
1430
protected List<string> ExtractFieldsFromODataFilter(string filter)
1531
{
1632
var fields = new List<string>();

Generator/Services/Plugins/PluginAnalyzer.cs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,8 @@ public override async Task AnalyzeComponentAsync(SDKStep sdkStep, Dictionary<str
2323

2424
// Populate the attributeUsages dictionary
2525
foreach (var attribute in filteringAttributes)
26-
{
27-
if (!attributeUsages.ContainsKey(logicalTableName))
28-
attributeUsages[logicalTableName] = new Dictionary<string, List<AttributeUsage>>();
26+
AddAttributeUsage(attributeUsages, logicalTableName, attribute, new AttributeUsage(pluginName, $"Used in filterattributes", OperationType.Other, SupportedType));
2927

30-
if (!attributeUsages[logicalTableName].ContainsKey(attribute))
31-
attributeUsages[logicalTableName][attribute] = new List<AttributeUsage>();
32-
33-
// Add the usage information (assuming AttributeUsage is a defined class)
34-
35-
attributeUsages[logicalTableName][attribute].Add(new AttributeUsage(pluginName, $"Used in filterattributes", OperationType.Other, SupportedType));
36-
}
3728
}
3829
catch (Exception ex)
3930
{

Generator/Services/Power Automate/PowerAutomateFlowAnalyzer.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -448,20 +448,4 @@ private OperationType DetermineOperationTypeFromAction(string actionName)
448448
// Default to Other for unknown actions
449449
return OperationType.Other;
450450
}
451-
452-
private void AddAttributeUsage(Dictionary<string, Dictionary<string, List<AttributeUsage>>> attributeUsages,
453-
string entityName, string attributeName, AttributeUsage usage)
454-
{
455-
if (!attributeUsages.ContainsKey(entityName))
456-
{
457-
attributeUsages[entityName] = new Dictionary<string, List<AttributeUsage>>();
458-
}
459-
460-
if (!attributeUsages[entityName].ContainsKey(attributeName))
461-
{
462-
attributeUsages[entityName][attributeName] = new List<AttributeUsage>();
463-
}
464-
465-
attributeUsages[entityName][attributeName].Add(usage);
466-
}
467451
}

0 commit comments

Comments
 (0)