From e669ab5ca20521773fb3b4e8ca4c65111d6ea602 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:12:52 +0000 Subject: [PATCH 01/14] Initial plan From 30af9856acd99f73dcd5d0794311727d37ddbff1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:23:23 +0000 Subject: [PATCH 02/14] Add enterprise reporting backend with YAML/JSON/CSV support Co-authored-by: mathis-m <11584315+mathis-m@users.noreply.github.com> --- .../src/DTOs/ReportDtos.cs | 361 +++++++++ .../src/Data/AnalyzerDbContext.cs | 34 + .../src/Ddk.SolutionLayerAnalyzer.csproj | 1 + .../src/Models/AnalyzerConfig.cs | 89 ++ .../src/Models/Report.cs | 77 ++ .../src/Models/ReportGroup.cs | 42 + .../src/Models/ReportSeverity.cs | 22 + .../src/Services/CsvHelper.cs | 128 +++ .../src/Services/ReportService.cs | 758 ++++++++++++++++++ .../src/SolutionLayerAnalyzerPlugin.cs | 308 +++++++ 10 files changed, 1820 insertions(+) create mode 100644 src/plugins/solution-layer-analyzer/src/DTOs/ReportDtos.cs create mode 100644 src/plugins/solution-layer-analyzer/src/Models/AnalyzerConfig.cs create mode 100644 src/plugins/solution-layer-analyzer/src/Models/Report.cs create mode 100644 src/plugins/solution-layer-analyzer/src/Models/ReportGroup.cs create mode 100644 src/plugins/solution-layer-analyzer/src/Models/ReportSeverity.cs create mode 100644 src/plugins/solution-layer-analyzer/src/Services/CsvHelper.cs create mode 100644 src/plugins/solution-layer-analyzer/src/Services/ReportService.cs diff --git a/src/plugins/solution-layer-analyzer/src/DTOs/ReportDtos.cs b/src/plugins/solution-layer-analyzer/src/DTOs/ReportDtos.cs new file mode 100644 index 0000000..9e6a13c --- /dev/null +++ b/src/plugins/solution-layer-analyzer/src/DTOs/ReportDtos.cs @@ -0,0 +1,361 @@ +using Ddk.SolutionLayerAnalyzer.Models; + +namespace Ddk.SolutionLayerAnalyzer.DTOs; + +// Request DTOs for report operations + +/// +/// Request to save a query/filter as a report +/// +public class SaveReportRequest +{ + public string ConnectionId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public int? GroupId { get; set; } + public ReportSeverity Severity { get; set; } = ReportSeverity.Information; + public string? RecommendedAction { get; set; } + public string QueryJson { get; set; } = string.Empty; + public string? OriginatingIndexHash { get; set; } +} + +/// +/// Request to update an existing report +/// +public class UpdateReportRequest +{ + public int Id { get; set; } + public string ConnectionId { get; set; } = string.Empty; + public string? Name { get; set; } + public string? Description { get; set; } + public int? GroupId { get; set; } + public ReportSeverity? Severity { get; set; } + public string? RecommendedAction { get; set; } + public string? QueryJson { get; set; } +} + +/// +/// Request to delete a report +/// +public class DeleteReportRequest +{ + public int Id { get; set; } + public string ConnectionId { get; set; } = string.Empty; +} + +/// +/// Request to duplicate a report +/// +public class DuplicateReportRequest +{ + public int Id { get; set; } + public string ConnectionId { get; set; } = string.Empty; + public string? NewName { get; set; } +} + +/// +/// Request to reorder reports +/// +public class ReorderReportsRequest +{ + public string ConnectionId { get; set; } = string.Empty; + public List Reports { get; set; } = new(); +} + +public class ReportOrder +{ + public int Id { get; set; } + public int DisplayOrder { get; set; } + public int? GroupId { get; set; } +} + +/// +/// Request to execute a saved report +/// +public class ExecuteReportRequest +{ + public int Id { get; set; } + public string ConnectionId { get; set; } = string.Empty; +} + +/// +/// Request to list all reports +/// +public class ListReportsRequest +{ + public string ConnectionId { get; set; } = string.Empty; +} + +/// +/// Request to create a report group +/// +public class CreateReportGroupRequest +{ + public string ConnectionId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; +} + +/// +/// Request to update a report group +/// +public class UpdateReportGroupRequest +{ + public int Id { get; set; } + public string ConnectionId { get; set; } = string.Empty; + public string? Name { get; set; } +} + +/// +/// Request to delete a report group +/// +public class DeleteReportGroupRequest +{ + public int Id { get; set; } + public string ConnectionId { get; set; } = string.Empty; +} + +/// +/// Request to reorder report groups +/// +public class ReorderReportGroupsRequest +{ + public string ConnectionId { get; set; } = string.Empty; + public List Groups { get; set; } = new(); +} + +public class GroupOrder +{ + public int Id { get; set; } + public int DisplayOrder { get; set; } +} + +/// +/// Request to export analyzer configuration to YAML +/// +public class ExportConfigRequest +{ + public string ConnectionId { get; set; } = string.Empty; + public string? FilePath { get; set; } +} + +/// +/// Request to import analyzer configuration from YAML +/// +public class ImportConfigRequest +{ + public string ConnectionId { get; set; } = string.Empty; + public string? FilePath { get; set; } + public string? ConfigYaml { get; set; } +} + +// Response DTOs + +/// +/// Response with report data +/// +public class ReportDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public int? GroupId { get; set; } + public string? GroupName { get; set; } + public ReportSeverity Severity { get; set; } + public string? RecommendedAction { get; set; } + public string QueryJson { get; set; } = string.Empty; + public int DisplayOrder { get; set; } + public string? OriginatingIndexHash { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? ModifiedAt { get; set; } + public DateTime? LastExecutedAt { get; set; } +} + +/// +/// Response with report group data +/// +public class ReportGroupDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int DisplayOrder { get; set; } + public List Reports { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime? ModifiedAt { get; set; } +} + +/// +/// Response with all reports organized by groups +/// +public class ListReportsResponse +{ + public List Groups { get; set; } = new(); + public List UngroupedReports { get; set; } = new(); +} + +/// +/// Response from executing a report +/// +public class ExecuteReportResponse +{ + public int ReportId { get; set; } + public string ReportName { get; set; } = string.Empty; + public ReportSeverity Severity { get; set; } + public int TotalMatches { get; set; } + public List Components { get; set; } = new(); + public DateTime ExecutedAt { get; set; } +} + +public class ReportComponentResult +{ + public string ComponentId { get; set; } = string.Empty; + public int ComponentType { get; set; } + public string ComponentTypeName { get; set; } = string.Empty; + public string? LogicalName { get; set; } + public string? DisplayName { get; set; } + public List Solutions { get; set; } = new(); +} + +/// +/// Response from exporting configuration +/// +public class ExportConfigResponse +{ + public string ConfigYaml { get; set; } = string.Empty; + public string? FilePath { get; set; } +} + +/// +/// Response from importing configuration +/// +public class ImportConfigResponse +{ + public int GroupsImported { get; set; } + public int ReportsImported { get; set; } + public List Warnings { get; set; } = new(); +} + +/// +/// Request to generate a report output file +/// +public class GenerateReportOutputRequest +{ + public string ConnectionId { get; set; } = string.Empty; + public int? ReportId { get; set; } + public List? ReportIds { get; set; } + public ReportOutputFormat Format { get; set; } = ReportOutputFormat.Yaml; + public ReportVerbosity Verbosity { get; set; } = ReportVerbosity.Basic; + public string? FilePath { get; set; } +} + +/// +/// Report output format +/// +public enum ReportOutputFormat +{ + Yaml, + Json, + Csv +} + +/// +/// Report verbosity level +/// +public enum ReportVerbosity +{ + /// + /// Basic - Components only with summary + /// + Basic, + + /// + /// Medium - Components + changed layer attribute list + /// + Medium, + + /// + /// Verbose - Components + layer attributes with full changed values + /// + Verbose +} + +/// +/// Response from generating report output +/// +public class GenerateReportOutputResponse +{ + public string OutputContent { get; set; } = string.Empty; + public string? FilePath { get; set; } + public ReportOutputFormat Format { get; set; } +} + +/// +/// Detailed report output structure +/// +public class ReportOutput +{ + public DateTime GeneratedAt { get; set; } + public string ConnectionId { get; set; } = string.Empty; + public ReportVerbosity Verbosity { get; set; } + public List Reports { get; set; } = new(); + public ReportSummary Summary { get; set; } = new(); +} + +/// +/// Individual report execution result in output +/// +public class ReportExecutionResult +{ + public string Name { get; set; } = string.Empty; + public string? Group { get; set; } + public ReportSeverity Severity { get; set; } + public string? RecommendedAction { get; set; } + public int TotalMatches { get; set; } + public List Components { get; set; } = new(); +} + +/// +/// Detailed component result with layer information +/// +public class DetailedComponentResult +{ + public string ComponentId { get; set; } = string.Empty; + public int ComponentType { get; set; } + public string ComponentTypeName { get; set; } = string.Empty; + public string? LogicalName { get; set; } + public string? DisplayName { get; set; } + public List? Solutions { get; set; } + public List? Layers { get; set; } + public string? MakePortalUrl { get; set; } +} + +/// +/// Layer information for verbose output +/// +public class LayerInfo +{ + public string SolutionName { get; set; } = string.Empty; + public int Ordinal { get; set; } + public List? ChangedAttributes { get; set; } +} + +/// +/// Attribute change information for verbose output +/// +public class AttributeChange +{ + public string AttributeName { get; set; } = string.Empty; + public string? OldValue { get; set; } + public string? NewValue { get; set; } +} + +/// +/// Summary of all report executions +/// +public class ReportSummary +{ + public int TotalReports { get; set; } + public int CriticalFindings { get; set; } + public int WarningFindings { get; set; } + public int InformationalFindings { get; set; } + public int TotalComponents { get; set; } +} diff --git a/src/plugins/solution-layer-analyzer/src/Data/AnalyzerDbContext.cs b/src/plugins/solution-layer-analyzer/src/Data/AnalyzerDbContext.cs index 015dd89..1b39205 100644 --- a/src/plugins/solution-layer-analyzer/src/Data/AnalyzerDbContext.cs +++ b/src/plugins/solution-layer-analyzer/src/Data/AnalyzerDbContext.cs @@ -54,6 +54,16 @@ public sealed class AnalyzerDbContext : DbContext /// public DbSet LayerAttributes => Set(); + /// + /// Gets or sets the ReportGroups table. + /// + public DbSet ReportGroups => Set(); + + /// + /// Gets or sets the Reports table. + /// + public DbSet Reports => Set(); + /// /// Initializes a new instance of the class. /// @@ -185,5 +195,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.LogicalName); entity.HasIndex(e => e.TableLogicalName); }); + + // Configure ReportGroup entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.ConnectionId); + entity.HasIndex(e => new { e.ConnectionId, e.DisplayOrder }); + entity.HasIndex(e => e.CreatedAt); + }); + + // Configure Report entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.ConnectionId); + entity.HasIndex(e => e.GroupId); + entity.HasIndex(e => new { e.ConnectionId, e.DisplayOrder }); + entity.HasIndex(e => e.Severity); + entity.HasIndex(e => e.CreatedAt); + entity.HasOne(e => e.Group) + .WithMany(e => e.Reports) + .HasForeignKey(e => e.GroupId) + .OnDelete(DeleteBehavior.SetNull); + }); } } diff --git a/src/plugins/solution-layer-analyzer/src/Ddk.SolutionLayerAnalyzer.csproj b/src/plugins/solution-layer-analyzer/src/Ddk.SolutionLayerAnalyzer.csproj index 681019f..3cb6f70 100644 --- a/src/plugins/solution-layer-analyzer/src/Ddk.SolutionLayerAnalyzer.csproj +++ b/src/plugins/solution-layer-analyzer/src/Ddk.SolutionLayerAnalyzer.csproj @@ -19,6 +19,7 @@ + diff --git a/src/plugins/solution-layer-analyzer/src/Models/AnalyzerConfig.cs b/src/plugins/solution-layer-analyzer/src/Models/AnalyzerConfig.cs new file mode 100644 index 0000000..2d6b58a --- /dev/null +++ b/src/plugins/solution-layer-analyzer/src/Models/AnalyzerConfig.cs @@ -0,0 +1,89 @@ +namespace Ddk.SolutionLayerAnalyzer.Models; + +/// +/// Represents the global configuration for the analyzer including settings and reports +/// +public class AnalyzerConfig +{ + /// + /// Source solutions to analyze + /// + public List SourceSolutions { get; set; } = new(); + + /// + /// Target solutions to compare against + /// + public List TargetSolutions { get; set; } = new(); + + /// + /// Component types to include in analysis + /// + public List? ComponentTypes { get; set; } + + /// + /// Report groups and their reports + /// + public List ReportGroups { get; set; } = new(); + + /// + /// Reports that are not in any group + /// + public List UngroupedReports { get; set; } = new(); +} + +/// +/// Report group for XML serialization +/// +public class ConfigReportGroup +{ + /// + /// Name of the group + /// + public string Name { get; set; } = string.Empty; + + /// + /// Display order + /// + public int DisplayOrder { get; set; } + + /// + /// Reports in this group + /// + public List Reports { get; set; } = new(); +} + +/// +/// Report configuration for XML serialization +/// +public class ConfigReport +{ + /// + /// Name of the report + /// + public string Name { get; set; } = string.Empty; + + /// + /// Description + /// + public string? Description { get; set; } + + /// + /// Severity level + /// + public ReportSeverity Severity { get; set; } = ReportSeverity.Information; + + /// + /// Recommended action + /// + public string? RecommendedAction { get; set; } + + /// + /// Filter query as JSON + /// + public string QueryJson { get; set; } = string.Empty; + + /// + /// Display order + /// + public int DisplayOrder { get; set; } +} diff --git a/src/plugins/solution-layer-analyzer/src/Models/Report.cs b/src/plugins/solution-layer-analyzer/src/Models/Report.cs new file mode 100644 index 0000000..9e53c48 --- /dev/null +++ b/src/plugins/solution-layer-analyzer/src/Models/Report.cs @@ -0,0 +1,77 @@ +namespace Ddk.SolutionLayerAnalyzer.Models; + +/// +/// Represents a saved report configuration with query and metadata +/// +public class Report +{ + /// + /// Unique identifier for the report + /// + public int Id { get; set; } + + /// + /// Connection ID / D365 environment identifier + /// + public string ConnectionId { get; set; } = string.Empty; + + /// + /// Name of the report + /// + public string Name { get; set; } = string.Empty; + + /// + /// Optional description of what this report checks + /// + public string? Description { get; set; } + + /// + /// Group ID this report belongs to (nullable) + /// + public int? GroupId { get; set; } + + /// + /// Navigation property to the group + /// + public ReportGroup? Group { get; set; } + + /// + /// Severity level of findings in this report + /// + public ReportSeverity Severity { get; set; } = ReportSeverity.Information; + + /// + /// Recommended action to take for matched components + /// + public string? RecommendedAction { get; set; } + + /// + /// JSON serialized FilterNode AST representing the query + /// + public string QueryJson { get; set; } = string.Empty; + + /// + /// Display order for sorting reports within a group + /// + public int DisplayOrder { get; set; } + + /// + /// Hash of originating index config for validation + /// + public string? OriginatingIndexHash { get; set; } + + /// + /// When this report was created + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When this report was last modified + /// + public DateTime? ModifiedAt { get; set; } + + /// + /// When this report was last executed + /// + public DateTime? LastExecutedAt { get; set; } +} diff --git a/src/plugins/solution-layer-analyzer/src/Models/ReportGroup.cs b/src/plugins/solution-layer-analyzer/src/Models/ReportGroup.cs new file mode 100644 index 0000000..0488b7d --- /dev/null +++ b/src/plugins/solution-layer-analyzer/src/Models/ReportGroup.cs @@ -0,0 +1,42 @@ +namespace Ddk.SolutionLayerAnalyzer.Models; + +/// +/// Represents a group for organizing reports +/// +public class ReportGroup +{ + /// + /// Unique identifier for the group + /// + public int Id { get; set; } + + /// + /// Connection ID / D365 environment identifier + /// + public string ConnectionId { get; set; } = string.Empty; + + /// + /// Name of the group + /// + public string Name { get; set; } = string.Empty; + + /// + /// Display order for sorting groups + /// + public int DisplayOrder { get; set; } + + /// + /// When this group was created + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When this group was last modified + /// + public DateTime? ModifiedAt { get; set; } + + /// + /// Navigation property for reports in this group + /// + public ICollection Reports { get; set; } = new List(); +} diff --git a/src/plugins/solution-layer-analyzer/src/Models/ReportSeverity.cs b/src/plugins/solution-layer-analyzer/src/Models/ReportSeverity.cs new file mode 100644 index 0000000..ea878ee --- /dev/null +++ b/src/plugins/solution-layer-analyzer/src/Models/ReportSeverity.cs @@ -0,0 +1,22 @@ +namespace Ddk.SolutionLayerAnalyzer.Models; + +/// +/// Severity levels for reports +/// +public enum ReportSeverity +{ + /// + /// Informational - no immediate action required + /// + Information = 0, + + /// + /// Warning - should be reviewed and potentially addressed + /// + Warning = 1, + + /// + /// Critical - requires immediate attention + /// + Critical = 2 +} diff --git a/src/plugins/solution-layer-analyzer/src/Services/CsvHelper.cs b/src/plugins/solution-layer-analyzer/src/Services/CsvHelper.cs new file mode 100644 index 0000000..a1c6431 --- /dev/null +++ b/src/plugins/solution-layer-analyzer/src/Services/CsvHelper.cs @@ -0,0 +1,128 @@ +using System.Text; +using Ddk.SolutionLayerAnalyzer.DTOs; + +namespace Ddk.SolutionLayerAnalyzer.Services; + +/// +/// Helper class for CSV serialization +/// +public static class CsvHelper +{ + /// + /// Convert report output to CSV format + /// + public static string SerializeReportOutput(ReportOutput reportOutput, ReportVerbosity verbosity) + { + var sb = new StringBuilder(); + + // Add summary section + sb.AppendLine("# SUMMARY"); + sb.AppendLine($"Generated At,{EscapeCsv(reportOutput.GeneratedAt.ToString("yyyy-MM-dd HH:mm:ss"))}"); + sb.AppendLine($"Connection ID,{EscapeCsv(reportOutput.ConnectionId)}"); + sb.AppendLine($"Verbosity,{reportOutput.Verbosity}"); + sb.AppendLine($"Total Reports,{reportOutput.Summary.TotalReports}"); + sb.AppendLine($"Critical Findings,{reportOutput.Summary.CriticalFindings}"); + sb.AppendLine($"Warning Findings,{reportOutput.Summary.WarningFindings}"); + sb.AppendLine($"Informational Findings,{reportOutput.Summary.InformationalFindings}"); + sb.AppendLine($"Total Components,{reportOutput.Summary.TotalComponents}"); + sb.AppendLine(); + + // Add detailed findings + sb.AppendLine("# DETAILED FINDINGS"); + + if (verbosity == ReportVerbosity.Basic) + { + // Basic format: Report, Group, Severity, Action, Component info + sb.AppendLine("Report Name,Group,Severity,Recommended Action,Component ID,Component Type,Logical Name,Display Name,Solutions,Make Portal URL"); + + foreach (var report in reportOutput.Reports) + { + foreach (var component in report.Components) + { + sb.AppendLine($"{EscapeCsv(report.Name)},{EscapeCsv(report.Group)},{report.Severity},{EscapeCsv(report.RecommendedAction)},{EscapeCsv(component.ComponentId)},{component.ComponentType},{EscapeCsv(component.LogicalName)},{EscapeCsv(component.DisplayName)},{EscapeCsv(string.Join("; ", component.Solutions ?? new List()))},{EscapeCsv(component.MakePortalUrl)}"); + } + } + } + else if (verbosity == ReportVerbosity.Medium) + { + // Medium format: Adds layer and changed attributes column + sb.AppendLine("Report Name,Group,Severity,Recommended Action,Component ID,Component Type,Logical Name,Display Name,Solution,Layer Ordinal,Changed Attributes,Make Portal URL"); + + foreach (var report in reportOutput.Reports) + { + foreach (var component in report.Components) + { + if (component.Layers != null && component.Layers.Any()) + { + foreach (var layer in component.Layers) + { + var changedAttrs = layer.ChangedAttributes != null + ? string.Join("; ", layer.ChangedAttributes.Select(a => a.AttributeName)) + : string.Empty; + + sb.AppendLine($"{EscapeCsv(report.Name)},{EscapeCsv(report.Group)},{report.Severity},{EscapeCsv(report.RecommendedAction)},{EscapeCsv(component.ComponentId)},{component.ComponentType},{EscapeCsv(component.LogicalName)},{EscapeCsv(component.DisplayName)},{EscapeCsv(layer.SolutionName)},{layer.Ordinal},{EscapeCsv(changedAttrs)},{EscapeCsv(component.MakePortalUrl)}"); + } + } + else + { + sb.AppendLine($"{EscapeCsv(report.Name)},{EscapeCsv(report.Group)},{report.Severity},{EscapeCsv(report.RecommendedAction)},{EscapeCsv(component.ComponentId)},{component.ComponentType},{EscapeCsv(component.LogicalName)},{EscapeCsv(component.DisplayName)},,,{EscapeCsv(component.MakePortalUrl)}"); + } + } + } + } + else // Verbose + { + // Verbose format: One row per attribute change + sb.AppendLine("Report Name,Group,Severity,Recommended Action,Component ID,Component Type,Logical Name,Display Name,Solution,Layer Ordinal,Attribute Name,Old Value,New Value,Make Portal URL"); + + foreach (var report in reportOutput.Reports) + { + foreach (var component in report.Components) + { + if (component.Layers != null && component.Layers.Any()) + { + foreach (var layer in component.Layers) + { + if (layer.ChangedAttributes != null && layer.ChangedAttributes.Any()) + { + foreach (var attr in layer.ChangedAttributes) + { + sb.AppendLine($"{EscapeCsv(report.Name)},{EscapeCsv(report.Group)},{report.Severity},{EscapeCsv(report.RecommendedAction)},{EscapeCsv(component.ComponentId)},{component.ComponentType},{EscapeCsv(component.LogicalName)},{EscapeCsv(component.DisplayName)},{EscapeCsv(layer.SolutionName)},{layer.Ordinal},{EscapeCsv(attr.AttributeName)},{EscapeCsv(attr.OldValue)},{EscapeCsv(attr.NewValue)},{EscapeCsv(component.MakePortalUrl)}"); + } + } + else + { + sb.AppendLine($"{EscapeCsv(report.Name)},{EscapeCsv(report.Group)},{report.Severity},{EscapeCsv(report.RecommendedAction)},{EscapeCsv(component.ComponentId)},{component.ComponentType},{EscapeCsv(component.LogicalName)},{EscapeCsv(component.DisplayName)},{EscapeCsv(layer.SolutionName)},{layer.Ordinal},,,{EscapeCsv(component.MakePortalUrl)}"); + } + } + } + else + { + sb.AppendLine($"{EscapeCsv(report.Name)},{EscapeCsv(report.Group)},{report.Severity},{EscapeCsv(report.RecommendedAction)},{EscapeCsv(component.ComponentId)},{component.ComponentType},{EscapeCsv(component.LogicalName)},{EscapeCsv(component.DisplayName)},,,,,,{EscapeCsv(component.MakePortalUrl)}"); + } + } + } + } + + return sb.ToString(); + } + + /// + /// Escape a CSV field value + /// + private static string EscapeCsv(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + // If the value contains comma, quote, or newline, wrap it in quotes and escape internal quotes + if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } +} diff --git a/src/plugins/solution-layer-analyzer/src/Services/ReportService.cs b/src/plugins/solution-layer-analyzer/src/Services/ReportService.cs new file mode 100644 index 0000000..b43967f --- /dev/null +++ b/src/plugins/solution-layer-analyzer/src/Services/ReportService.cs @@ -0,0 +1,758 @@ +using System.Text.Json; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Ddk.SolutionLayerAnalyzer.Data; +using Ddk.SolutionLayerAnalyzer.Models; +using Ddk.SolutionLayerAnalyzer.DTOs; +using Ddk.SolutionLayerAnalyzer.Filters; + +namespace Ddk.SolutionLayerAnalyzer.Services; + +/// +/// Service for managing reports and report groups +/// +public class ReportService +{ + private readonly AnalyzerDbContext _context; + private readonly ILogger _logger; + private readonly QueryService _queryService; + + public ReportService(AnalyzerDbContext context, ILogger logger, QueryService queryService) + { + _context = context; + _logger = logger; + _queryService = queryService; + } + + /// + /// Save a new report + /// + public async Task SaveReportAsync(SaveReportRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Saving new report: {Name}", request.Name); + + // Get the next display order + var maxOrder = await _context.Reports + .Where(r => r.ConnectionId == request.ConnectionId && r.GroupId == request.GroupId) + .MaxAsync(r => (int?)r.DisplayOrder, cancellationToken) ?? 0; + + var report = new Report + { + ConnectionId = request.ConnectionId, + Name = request.Name, + Description = request.Description, + GroupId = request.GroupId, + Severity = request.Severity, + RecommendedAction = request.RecommendedAction, + QueryJson = request.QueryJson, + OriginatingIndexHash = request.OriginatingIndexHash, + DisplayOrder = maxOrder + 1, + CreatedAt = DateTime.UtcNow + }; + + _context.Reports.Add(report); + await _context.SaveChangesAsync(cancellationToken); + + return await GetReportDtoAsync(report.Id, request.ConnectionId, cancellationToken); + } + + /// + /// Update an existing report + /// + public async Task UpdateReportAsync(UpdateReportRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Updating report: {Id}", request.Id); + + var report = await _context.Reports + .FirstOrDefaultAsync(r => r.Id == request.Id && r.ConnectionId == request.ConnectionId, cancellationToken) + ?? throw new ArgumentException($"Report {request.Id} not found"); + + if (request.Name != null) report.Name = request.Name; + if (request.Description != null) report.Description = request.Description; + if (request.GroupId.HasValue) report.GroupId = request.GroupId; + if (request.Severity.HasValue) report.Severity = request.Severity.Value; + if (request.RecommendedAction != null) report.RecommendedAction = request.RecommendedAction; + if (request.QueryJson != null) report.QueryJson = request.QueryJson; + + report.ModifiedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(cancellationToken); + + return await GetReportDtoAsync(report.Id, request.ConnectionId, cancellationToken); + } + + /// + /// Delete a report + /// + public async Task DeleteReportAsync(DeleteReportRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Deleting report: {Id}", request.Id); + + var report = await _context.Reports + .FirstOrDefaultAsync(r => r.Id == request.Id && r.ConnectionId == request.ConnectionId, cancellationToken) + ?? throw new ArgumentException($"Report {request.Id} not found"); + + _context.Reports.Remove(report); + await _context.SaveChangesAsync(cancellationToken); + } + + /// + /// Duplicate a report + /// + public async Task DuplicateReportAsync(DuplicateReportRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Duplicating report: {Id}", request.Id); + + var original = await _context.Reports + .FirstOrDefaultAsync(r => r.Id == request.Id && r.ConnectionId == request.ConnectionId, cancellationToken) + ?? throw new ArgumentException($"Report {request.Id} not found"); + + // Get the next display order + var maxOrder = await _context.Reports + .Where(r => r.ConnectionId == request.ConnectionId && r.GroupId == original.GroupId) + .MaxAsync(r => (int?)r.DisplayOrder, cancellationToken) ?? 0; + + var duplicate = new Report + { + ConnectionId = original.ConnectionId, + Name = request.NewName ?? $"{original.Name} (Copy)", + Description = original.Description, + GroupId = original.GroupId, + Severity = original.Severity, + RecommendedAction = original.RecommendedAction, + QueryJson = original.QueryJson, + OriginatingIndexHash = original.OriginatingIndexHash, + DisplayOrder = maxOrder + 1, + CreatedAt = DateTime.UtcNow + }; + + _context.Reports.Add(duplicate); + await _context.SaveChangesAsync(cancellationToken); + + return await GetReportDtoAsync(duplicate.Id, request.ConnectionId, cancellationToken); + } + + /// + /// Reorder reports + /// + public async Task ReorderReportsAsync(ReorderReportsRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Reordering {Count} reports", request.Reports.Count); + + var reportIds = request.Reports.Select(r => r.Id).ToList(); + var reports = await _context.Reports + .Where(r => reportIds.Contains(r.Id) && r.ConnectionId == request.ConnectionId) + .ToListAsync(cancellationToken); + + foreach (var orderUpdate in request.Reports) + { + var report = reports.FirstOrDefault(r => r.Id == orderUpdate.Id); + if (report != null) + { + report.DisplayOrder = orderUpdate.DisplayOrder; + if (orderUpdate.GroupId != report.GroupId) + { + report.GroupId = orderUpdate.GroupId; + } + report.ModifiedAt = DateTime.UtcNow; + } + } + + await _context.SaveChangesAsync(cancellationToken); + } + + /// + /// Execute a saved report + /// + public async Task ExecuteReportAsync(ExecuteReportRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Executing report: {Id}", request.Id); + + var report = await _context.Reports + .FirstOrDefaultAsync(r => r.Id == request.Id && r.ConnectionId == request.ConnectionId, cancellationToken) + ?? throw new ArgumentException($"Report {request.Id} not found"); + + // Deserialize the filter query + var filterNode = JsonSerializer.Deserialize(report.QueryJson); + if (filterNode == null) + { + throw new InvalidOperationException("Invalid query JSON in report"); + } + + // Execute the query using QueryService + var queryRequest = new QueryRequest + { + Filters = filterNode + }; + + var queryResponse = await _queryService.QueryAsync(queryRequest, cancellationToken); + + // Map to component results + var componentResults = queryResponse.Rows.Select(c => + { + // Map component type string to code + var typeCode = c.ComponentType switch + { + "Entity" => ComponentTypeCodes.Entity, + "Attribute" => ComponentTypeCodes.Attribute, + "SystemForm" => ComponentTypeCodes.SystemForm, + "SavedQuery" => ComponentTypeCodes.SavedQuery, + "SavedQueryVisualization" => ComponentTypeCodes.SavedQueryVisualization, + "RibbonCustomization" => ComponentTypeCodes.RibbonCustomization, + "WebResource" => ComponentTypeCodes.WebResource, + "SDKMessageProcessingStep" => ComponentTypeCodes.SDKMessageProcessingStep, + "Workflow" => ComponentTypeCodes.Workflow, + "AppModule" => ComponentTypeCodes.AppModule, + "SiteMap" => ComponentTypeCodes.SiteMap, + "OptionSet" => ComponentTypeCodes.OptionSet, + _ => 0 + }; + + return new ReportComponentResult + { + ComponentId = c.ComponentId.ToString(), + ComponentType = typeCode, + ComponentTypeName = c.ComponentType, + LogicalName = c.LogicalName, + DisplayName = c.DisplayName, + Solutions = c.LayerSequence + }; + }).ToList(); + + // Update last executed timestamp + report.LastExecutedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(cancellationToken); + + return new ExecuteReportResponse + { + ReportId = report.Id, + ReportName = report.Name, + Severity = report.Severity, + TotalMatches = componentResults.Count, + Components = componentResults, + ExecutedAt = DateTime.UtcNow + }; + } + + /// + /// List all reports organized by groups + /// + public async Task ListReportsAsync(string connectionId, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Listing reports for connection: {ConnectionId}", connectionId); + + var groups = await _context.ReportGroups + .Where(g => g.ConnectionId == connectionId) + .Include(g => g.Reports) + .OrderBy(g => g.DisplayOrder) + .ToListAsync(cancellationToken); + + var ungroupedReports = await _context.Reports + .Where(r => r.ConnectionId == connectionId && r.GroupId == null) + .OrderBy(r => r.DisplayOrder) + .ToListAsync(cancellationToken); + + var response = new ListReportsResponse + { + Groups = groups.Select(g => new ReportGroupDto + { + Id = g.Id, + Name = g.Name, + DisplayOrder = g.DisplayOrder, + CreatedAt = g.CreatedAt, + ModifiedAt = g.ModifiedAt, + Reports = g.Reports.OrderBy(r => r.DisplayOrder).Select(MapToDto).ToList() + }).ToList(), + UngroupedReports = ungroupedReports.Select(MapToDto).ToList() + }; + + return response; + } + + /// + /// Create a new report group + /// + public async Task CreateReportGroupAsync(CreateReportGroupRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Creating report group: {Name}", request.Name); + + var maxOrder = await _context.ReportGroups + .Where(g => g.ConnectionId == request.ConnectionId) + .MaxAsync(g => (int?)g.DisplayOrder, cancellationToken) ?? 0; + + var group = new ReportGroup + { + ConnectionId = request.ConnectionId, + Name = request.Name, + DisplayOrder = maxOrder + 1, + CreatedAt = DateTime.UtcNow + }; + + _context.ReportGroups.Add(group); + await _context.SaveChangesAsync(cancellationToken); + + return new ReportGroupDto + { + Id = group.Id, + Name = group.Name, + DisplayOrder = group.DisplayOrder, + CreatedAt = group.CreatedAt, + Reports = new List() + }; + } + + /// + /// Update a report group + /// + public async Task UpdateReportGroupAsync(UpdateReportGroupRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Updating report group: {Id}", request.Id); + + var group = await _context.ReportGroups + .Include(g => g.Reports) + .FirstOrDefaultAsync(g => g.Id == request.Id && g.ConnectionId == request.ConnectionId, cancellationToken) + ?? throw new ArgumentException($"Report group {request.Id} not found"); + + if (request.Name != null) group.Name = request.Name; + group.ModifiedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(cancellationToken); + + return new ReportGroupDto + { + Id = group.Id, + Name = group.Name, + DisplayOrder = group.DisplayOrder, + CreatedAt = group.CreatedAt, + ModifiedAt = group.ModifiedAt, + Reports = group.Reports.OrderBy(r => r.DisplayOrder).Select(MapToDto).ToList() + }; + } + + /// + /// Delete a report group (reports in the group will become ungrouped) + /// + public async Task DeleteReportGroupAsync(DeleteReportGroupRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Deleting report group: {Id}", request.Id); + + var group = await _context.ReportGroups + .FirstOrDefaultAsync(g => g.Id == request.Id && g.ConnectionId == request.ConnectionId, cancellationToken) + ?? throw new ArgumentException($"Report group {request.Id} not found"); + + _context.ReportGroups.Remove(group); + await _context.SaveChangesAsync(cancellationToken); + } + + /// + /// Reorder report groups + /// + public async Task ReorderReportGroupsAsync(ReorderReportGroupsRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Reordering {Count} report groups", request.Groups.Count); + + var groupIds = request.Groups.Select(g => g.Id).ToList(); + var groups = await _context.ReportGroups + .Where(g => groupIds.Contains(g.Id) && g.ConnectionId == request.ConnectionId) + .ToListAsync(cancellationToken); + + foreach (var orderUpdate in request.Groups) + { + var group = groups.FirstOrDefault(g => g.Id == orderUpdate.Id); + if (group != null) + { + group.DisplayOrder = orderUpdate.DisplayOrder; + group.ModifiedAt = DateTime.UtcNow; + } + } + + await _context.SaveChangesAsync(cancellationToken); + } + + /// + /// Export analyzer configuration to YAML + /// + public async Task ExportConfigAsync(ExportConfigRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Exporting configuration for connection: {ConnectionId}", request.ConnectionId); + + // Get the saved index config to populate source/target solutions + var indexConfig = await _context.SavedIndexConfigs + .Where(c => c.ConnectionId == request.ConnectionId) + .OrderByDescending(c => c.LastUsedAt ?? c.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + var groups = await _context.ReportGroups + .Where(g => g.ConnectionId == request.ConnectionId) + .Include(g => g.Reports) + .OrderBy(g => g.DisplayOrder) + .ToListAsync(cancellationToken); + + var ungroupedReports = await _context.Reports + .Where(r => r.ConnectionId == request.ConnectionId && r.GroupId == null) + .OrderBy(r => r.DisplayOrder) + .ToListAsync(cancellationToken); + + var config = new AnalyzerConfig + { + SourceSolutions = indexConfig?.SourceSolutions.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList() ?? new List(), + TargetSolutions = indexConfig?.TargetSolutions.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList() ?? new List(), + ComponentTypes = indexConfig?.ComponentTypes.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse).ToList(), + ReportGroups = groups.Select(g => new ConfigReportGroup + { + Name = g.Name, + DisplayOrder = g.DisplayOrder, + Reports = g.Reports.OrderBy(r => r.DisplayOrder).Select(MapToConfigReport).ToList() + }).ToList(), + UngroupedReports = ungroupedReports.Select(MapToConfigReport).ToList() + }; + + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var yaml = serializer.Serialize(config); + + if (!string.IsNullOrEmpty(request.FilePath)) + { + await File.WriteAllTextAsync(request.FilePath, yaml, cancellationToken); + } + + return new ExportConfigResponse + { + ConfigYaml = yaml, + FilePath = request.FilePath + }; + } + + /// + /// Import analyzer configuration from YAML + /// + public async Task ImportConfigAsync(ImportConfigRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Importing configuration for connection: {ConnectionId}", request.ConnectionId); + + var yaml = request.ConfigYaml; + if (string.IsNullOrEmpty(yaml) && !string.IsNullOrEmpty(request.FilePath)) + { + yaml = await File.ReadAllTextAsync(request.FilePath, cancellationToken); + } + + if (string.IsNullOrEmpty(yaml)) + { + throw new ArgumentException("Either ConfigYaml or FilePath must be provided"); + } + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var config = deserializer.Deserialize(yaml); + + var warnings = new List(); + var groupsImported = 0; + var reportsImported = 0; + + // Import report groups + var groupIdMap = new Dictionary(); // Old name to new ID + foreach (var configGroup in config.ReportGroups) + { + var group = new ReportGroup + { + ConnectionId = request.ConnectionId, + Name = configGroup.Name, + DisplayOrder = configGroup.DisplayOrder, + CreatedAt = DateTime.UtcNow + }; + + _context.ReportGroups.Add(group); + await _context.SaveChangesAsync(cancellationToken); + + groupIdMap[configGroup.Name] = group.Id; + groupsImported++; + + // Import reports in this group + foreach (var configReport in configGroup.Reports) + { + var report = new Report + { + ConnectionId = request.ConnectionId, + Name = configReport.Name, + Description = configReport.Description, + GroupId = group.Id, + Severity = configReport.Severity, + RecommendedAction = configReport.RecommendedAction, + QueryJson = configReport.QueryJson, + DisplayOrder = configReport.DisplayOrder, + CreatedAt = DateTime.UtcNow + }; + + _context.Reports.Add(report); + reportsImported++; + } + } + + // Import ungrouped reports + foreach (var configReport in config.UngroupedReports) + { + var report = new Report + { + ConnectionId = request.ConnectionId, + Name = configReport.Name, + Description = configReport.Description, + GroupId = null, + Severity = configReport.Severity, + RecommendedAction = configReport.RecommendedAction, + QueryJson = configReport.QueryJson, + DisplayOrder = configReport.DisplayOrder, + CreatedAt = DateTime.UtcNow + }; + + _context.Reports.Add(report); + reportsImported++; + } + + await _context.SaveChangesAsync(cancellationToken); + + return new ImportConfigResponse + { + GroupsImported = groupsImported, + ReportsImported = reportsImported, + Warnings = warnings + }; + } + + /// + /// Generate report output in YAML or JSON format + /// + public async Task GenerateReportOutputAsync(GenerateReportOutputRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Generating report output for connection: {ConnectionId}", request.ConnectionId); + + var reportIds = request.ReportId.HasValue + ? new List { request.ReportId.Value } + : request.ReportIds ?? new List(); + + if (!reportIds.Any()) + { + // Get all reports if none specified + reportIds = await _context.Reports + .Where(r => r.ConnectionId == request.ConnectionId) + .Select(r => r.Id) + .ToListAsync(cancellationToken); + } + + var reportResults = new List(); + var totalCritical = 0; + var totalWarning = 0; + var totalInfo = 0; + var allComponentIds = new HashSet(); + + foreach (var reportId in reportIds) + { + var report = await _context.Reports + .Include(r => r.Group) + .FirstOrDefaultAsync(r => r.Id == reportId && r.ConnectionId == request.ConnectionId, cancellationToken); + + if (report == null) continue; + + // Execute the report + var executeRequest = new ExecuteReportRequest + { + Id = reportId, + ConnectionId = request.ConnectionId + }; + var executeResponse = await ExecuteReportAsync(executeRequest, cancellationToken); + + // Map to detailed results based on verbosity + var detailedComponents = await MapToDetailedComponentsAsync( + executeResponse.Components, + request.Verbosity, + request.ConnectionId, + cancellationToken); + + var result = new ReportExecutionResult + { + Name = report.Name, + Group = report.Group?.Name, + Severity = report.Severity, + RecommendedAction = report.RecommendedAction, + TotalMatches = executeResponse.TotalMatches, + Components = detailedComponents + }; + + reportResults.Add(result); + + // Update summary counts + foreach (var comp in executeResponse.Components) + { + allComponentIds.Add(comp.ComponentId); + } + + if (report.Severity == ReportSeverity.Critical) + totalCritical += executeResponse.TotalMatches; + else if (report.Severity == ReportSeverity.Warning) + totalWarning += executeResponse.TotalMatches; + else + totalInfo += executeResponse.TotalMatches; + } + + var reportOutput = new ReportOutput + { + GeneratedAt = DateTime.UtcNow, + ConnectionId = request.ConnectionId, + Verbosity = request.Verbosity, + Reports = reportResults, + Summary = new ReportSummary + { + TotalReports = reportResults.Count, + CriticalFindings = totalCritical, + WarningFindings = totalWarning, + InformationalFindings = totalInfo, + TotalComponents = allComponentIds.Count + } + }; + + string outputContent; + if (request.Format == ReportOutputFormat.Yaml) + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + outputContent = serializer.Serialize(reportOutput); + } + else if (request.Format == ReportOutputFormat.Json) + { + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + outputContent = JsonSerializer.Serialize(reportOutput, options); + } + else // CSV + { + outputContent = CsvHelper.SerializeReportOutput(reportOutput, request.Verbosity); + } + + if (!string.IsNullOrEmpty(request.FilePath)) + { + await File.WriteAllTextAsync(request.FilePath, outputContent, cancellationToken); + } + + return new GenerateReportOutputResponse + { + OutputContent = outputContent, + FilePath = request.FilePath, + Format = request.Format + }; + } + + private async Task> MapToDetailedComponentsAsync( + List components, + ReportVerbosity verbosity, + string connectionId, + CancellationToken cancellationToken) + { + var results = new List(); + + foreach (var component in components) + { + var detailed = new DetailedComponentResult + { + ComponentId = component.ComponentId, + ComponentType = component.ComponentType, + ComponentTypeName = component.ComponentTypeName, + LogicalName = component.LogicalName, + DisplayName = component.DisplayName, + Solutions = component.Solutions, + MakePortalUrl = $"https://make.powerapps.com/environments/{connectionId}" + }; + + // Add layer information based on verbosity + if (verbosity != ReportVerbosity.Basic) + { + var dbComponent = await _context.Components + .Include(c => c.Layers) + .ThenInclude(l => l.Attributes) + .FirstOrDefaultAsync(c => c.ComponentId.ToString() == component.ComponentId, cancellationToken); + + if (dbComponent != null) + { + detailed.Layers = dbComponent.Layers + .OrderBy(l => l.Ordinal) + .Select(l => new LayerInfo + { + SolutionName = l.SolutionName, + Ordinal = l.Ordinal, + ChangedAttributes = verbosity == ReportVerbosity.Verbose + ? l.Attributes + .Where(a => a.IsChanged) + .Select(a => new AttributeChange + { + AttributeName = a.AttributeName, + OldValue = a.RawValue, + NewValue = a.AttributeValue + }).ToList() + : (verbosity == ReportVerbosity.Medium + ? l.Attributes + .Where(a => a.IsChanged) + .Select(a => new AttributeChange + { + AttributeName = a.AttributeName + }).ToList() + : null) + }).ToList(); + } + } + + results.Add(detailed); + } + + return results; + } + + private async Task GetReportDtoAsync(int reportId, string connectionId, CancellationToken cancellationToken) + { + var report = await _context.Reports + .Include(r => r.Group) + .FirstOrDefaultAsync(r => r.Id == reportId && r.ConnectionId == connectionId, cancellationToken) + ?? throw new ArgumentException($"Report {reportId} not found"); + + return MapToDto(report); + } + + private static ReportDto MapToDto(Report report) + { + return new ReportDto + { + Id = report.Id, + Name = report.Name, + Description = report.Description, + GroupId = report.GroupId, + GroupName = report.Group?.Name, + Severity = report.Severity, + RecommendedAction = report.RecommendedAction, + QueryJson = report.QueryJson, + DisplayOrder = report.DisplayOrder, + OriginatingIndexHash = report.OriginatingIndexHash, + CreatedAt = report.CreatedAt, + ModifiedAt = report.ModifiedAt, + LastExecutedAt = report.LastExecutedAt + }; + } + + private static ConfigReport MapToConfigReport(Report report) + { + return new ConfigReport + { + Name = report.Name, + Description = report.Description, + Severity = report.Severity, + RecommendedAction = report.RecommendedAction, + QueryJson = report.QueryJson, + DisplayOrder = report.DisplayOrder + }; + } +} diff --git a/src/plugins/solution-layer-analyzer/src/SolutionLayerAnalyzerPlugin.cs b/src/plugins/solution-layer-analyzer/src/SolutionLayerAnalyzerPlugin.cs index 781d7da..2392dc6 100644 --- a/src/plugins/solution-layer-analyzer/src/SolutionLayerAnalyzerPlugin.cs +++ b/src/plugins/solution-layer-analyzer/src/SolutionLayerAnalyzerPlugin.cs @@ -319,6 +319,90 @@ public Task> GetCommandsAsync(CancellationToken can Name = "getAnalytics", Label = "Get Analytics", Description = "Get comprehensive analytics including risk scores, violations, and graph data" + }, + new() + { + Name = "saveReport", + Label = "Save Report", + Description = "Save a query/filter as a report configuration" + }, + new() + { + Name = "updateReport", + Label = "Update Report", + Description = "Update an existing report configuration" + }, + new() + { + Name = "deleteReport", + Label = "Delete Report", + Description = "Delete a report" + }, + new() + { + Name = "duplicateReport", + Label = "Duplicate Report", + Description = "Create a copy of an existing report" + }, + new() + { + Name = "listReports", + Label = "List Reports", + Description = "List all reports organized by groups" + }, + new() + { + Name = "executeReport", + Label = "Execute Report", + Description = "Execute a saved report and get results" + }, + new() + { + Name = "reorderReports", + Label = "Reorder Reports", + Description = "Reorder reports and change their grouping" + }, + new() + { + Name = "createReportGroup", + Label = "Create Report Group", + Description = "Create a new report group" + }, + new() + { + Name = "updateReportGroup", + Label = "Update Report Group", + Description = "Update a report group" + }, + new() + { + Name = "deleteReportGroup", + Label = "Delete Report Group", + Description = "Delete a report group" + }, + new() + { + Name = "reorderReportGroups", + Label = "Reorder Report Groups", + Description = "Reorder report groups" + }, + new() + { + Name = "exportConfig", + Label = "Export Configuration", + Description = "Export analyzer configuration to YAML file" + }, + new() + { + Name = "importConfig", + Label = "Import Configuration", + Description = "Import analyzer configuration from YAML file" + }, + new() + { + Name = "generateReportOutput", + Label = "Generate Report Output", + Description = "Generate detailed report output in YAML or JSON format" } }; @@ -356,6 +440,20 @@ public async Task ExecuteAsync(string commandName, string payload, "loadIndexConfigs" => await ExecuteLoadIndexConfigsAsync(payload, cancellationToken), "saveFilterConfig" => await ExecuteSaveFilterConfigAsync(payload, cancellationToken), "loadFilterConfigs" => await ExecuteLoadFilterConfigsAsync(payload, cancellationToken), + "saveReport" => await ExecuteSaveReportAsync(payload, cancellationToken), + "updateReport" => await ExecuteUpdateReportAsync(payload, cancellationToken), + "deleteReport" => await ExecuteDeleteReportAsync(payload, cancellationToken), + "duplicateReport" => await ExecuteDuplicateReportAsync(payload, cancellationToken), + "listReports" => await ExecuteListReportsAsync(payload, cancellationToken), + "executeReport" => await ExecuteExecuteReportAsync(payload, cancellationToken), + "reorderReports" => await ExecuteReorderReportsAsync(payload, cancellationToken), + "createReportGroup" => await ExecuteCreateReportGroupAsync(payload, cancellationToken), + "updateReportGroup" => await ExecuteUpdateReportGroupAsync(payload, cancellationToken), + "deleteReportGroup" => await ExecuteDeleteReportGroupAsync(payload, cancellationToken), + "reorderReportGroups" => await ExecuteReorderReportGroupsAsync(payload, cancellationToken), + "exportConfig" => await ExecuteExportConfigAsync(payload, cancellationToken), + "importConfig" => await ExecuteImportConfigAsync(payload, cancellationToken), + "generateReportOutput" => await ExecuteGenerateReportOutputAsync(payload, cancellationToken), _ => throw new ArgumentException($"Unknown command: {commandName}", nameof(commandName)) }; } @@ -874,6 +972,216 @@ private async Task ExecuteGetAnalyticsAsync(string payload, Cancell return JsonSerializer.SerializeToElement(analytics, JsonOptions); } + private async Task ExecuteSaveReportAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid saveReport request payload", nameof(payload)); + + _context!.Logger.LogInformation("Saving report: {Name}", request.Name); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.SaveReportAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteUpdateReportAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid updateReport request payload", nameof(payload)); + + _context!.Logger.LogInformation("Updating report: {Id}", request.Id); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.UpdateReportAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteDeleteReportAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid deleteReport request payload", nameof(payload)); + + _context!.Logger.LogInformation("Deleting report: {Id}", request.Id); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + await reportService.DeleteReportAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(new { success = true }, JsonOptions); + } + + private async Task ExecuteDuplicateReportAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid duplicateReport request payload", nameof(payload)); + + _context!.Logger.LogInformation("Duplicating report: {Id}", request.Id); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.DuplicateReportAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteListReportsAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid listReports request payload", nameof(payload)); + + _context!.Logger.LogInformation("Listing reports"); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.ListReportsAsync(request.ConnectionId, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteExecuteReportAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid executeReport request payload", nameof(payload)); + + _context!.Logger.LogInformation("Executing report: {Id}", request.Id); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.ExecuteReportAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteReorderReportsAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid reorderReports request payload", nameof(payload)); + + _context!.Logger.LogInformation("Reordering reports"); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + await reportService.ReorderReportsAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(new { success = true }, JsonOptions); + } + + private async Task ExecuteCreateReportGroupAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid createReportGroup request payload", nameof(payload)); + + _context!.Logger.LogInformation("Creating report group: {Name}", request.Name); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.CreateReportGroupAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteUpdateReportGroupAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid updateReportGroup request payload", nameof(payload)); + + _context!.Logger.LogInformation("Updating report group: {Id}", request.Id); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.UpdateReportGroupAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteDeleteReportGroupAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid deleteReportGroup request payload", nameof(payload)); + + _context!.Logger.LogInformation("Deleting report group: {Id}", request.Id); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + await reportService.DeleteReportGroupAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(new { success = true }, JsonOptions); + } + + private async Task ExecuteReorderReportGroupsAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid reorderReportGroups request payload", nameof(payload)); + + _context!.Logger.LogInformation("Reordering report groups"); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + await reportService.ReorderReportGroupsAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(new { success = true }, JsonOptions); + } + + private async Task ExecuteExportConfigAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid exportConfig request payload", nameof(payload)); + + _context!.Logger.LogInformation("Exporting configuration"); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.ExportConfigAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteImportConfigAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid importConfig request payload", nameof(payload)); + + _context!.Logger.LogInformation("Importing configuration"); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.ImportConfigAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + + private async Task ExecuteGenerateReportOutputAsync(string payload, CancellationToken cancellationToken) + { + var request = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new ArgumentException("Invalid generateReportOutput request payload", nameof(payload)); + + _context!.Logger.LogInformation("Generating report output"); + + await using var dbContext = CreateDbContext(request.ConnectionId); + var queryService = new QueryService(dbContext); + var reportService = new ReportService(dbContext, _context.Logger, queryService); + var response = await reportService.GenerateReportOutputAsync(request, cancellationToken); + + return JsonSerializer.SerializeToElement(response, JsonOptions); + } + /// public Task DisposeAsync() { From 9447227395a7f85e61b41177310487a0dda53788 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:26:46 +0000 Subject: [PATCH 03/14] Add CLI project with YAML config support and documentation Co-authored-by: mathis-m <11584315+mathis-m@users.noreply.github.com> --- ...ataverseDevKit.SolutionAnalyzer.CLI.csproj | 24 ++ .../FileLogger.cs | 78 ++++++ .../Program.cs | 163 +++++++++++ .../SolutionAnalyzerCli.cs | 241 ++++++++++++++++ src/cli/solution-analyzer/README.md | 260 ++++++++++++++++++ src/cli/solution-analyzer/example-config.yaml | 53 ++++ 6 files changed, 819 insertions(+) create mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj create mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/FileLogger.cs create mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs create mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs create mode 100644 src/cli/solution-analyzer/README.md create mode 100644 src/cli/solution-analyzer/example-config.yaml diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj new file mode 100644 index 0000000..c2b709a --- /dev/null +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + ddk-solution-analyzer + DataverseDevKit.SolutionAnalyzer.CLI + + + + + + + + + + + + + + + diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/FileLogger.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/FileLogger.cs new file mode 100644 index 0000000..0b69b6a --- /dev/null +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/FileLogger.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging; + +namespace DataverseDevKit.SolutionAnalyzer.CLI; + +/// +/// Simple file logger for CLI output +/// +public class FileLogger : ILogger +{ + private readonly string _logFilePath; + private readonly LogLevel _minLogLevel; + private readonly object _lock = new(); + + public FileLogger(string logFilePath, LogLevel minLogLevel = LogLevel.Information) + { + _logFilePath = logFilePath; + _minLogLevel = minLogLevel; + + // Ensure directory exists + var directory = Path.GetDirectoryName(logFilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // Clear or create log file + File.WriteAllText(logFilePath, $"=== CLI Log Started at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC ==={Environment.NewLine}"); + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLogLevel; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + var logEntry = $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] [{logLevel}] {message}"; + + if (exception != null) + { + logEntry += $"{Environment.NewLine}{exception}"; + } + + lock (_lock) + { + File.AppendAllText(_logFilePath, logEntry + Environment.NewLine); + } + } +} + +/// +/// Simple logger factory for file logging +/// +public class FileLoggerProvider : ILoggerProvider +{ + private readonly string _logFilePath; + private readonly LogLevel _minLogLevel; + + public FileLoggerProvider(string logFilePath, LogLevel minLogLevel = LogLevel.Information) + { + _logFilePath = logFilePath; + _minLogLevel = minLogLevel; + } + + public ILogger CreateLogger(string categoryName) + { + return new FileLogger(_logFilePath, _minLogLevel); + } + + public void Dispose() + { + } +} diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs new file mode 100644 index 0000000..22dabeb --- /dev/null +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs @@ -0,0 +1,163 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using Microsoft.Extensions.Logging; +using DataverseDevKit.SolutionAnalyzer.CLI; + +// Root command +var rootCommand = new RootCommand("Dataverse DevKit Solution Layer Analyzer CLI - Enterprise reporting tool for Dataverse solution analysis"); + +// Global options +var configOption = new Option( + aliases: new[] { "--config", "-c" }, + description: "Path to the YAML configuration file" +); +configOption.IsRequired = true; + +var connectionStringOption = new Option( + aliases: new[] { "--connection-string", "-cs" }, + description: "Dataverse connection string (alternative to interactive auth)" +); + +var clientIdOption = new Option( + aliases: new[] { "--client-id" }, + description: "Azure AD client ID for service principal authentication" +); + +var clientSecretOption = new Option( + aliases: new[] { "--client-secret" }, + description: "Azure AD client secret for service principal authentication" +); + +var tenantIdOption = new Option( + aliases: new[] { "--tenant-id" }, + description: "Azure AD tenant ID for service principal authentication" +); + +var environmentUrlOption = new Option( + aliases: new[] { "--environment-url", "-e" }, + description: "Dataverse environment URL" +); + +var verbosityOption = new Option( + aliases: new[] { "--verbosity", "-v" }, + getDefaultValue: () => "normal", + description: "Log verbosity level (quiet, minimal, normal, detailed, diagnostic)" +); + +var outputPathOption = new Option( + aliases: new[] { "--output", "-o" }, + getDefaultValue: () => new DirectoryInfo("."), + description: "Output directory for reports and logs" +); + +var reportFormatOption = new Option( + aliases: new[] { "--format", "-f" }, + getDefaultValue: () => "yaml", + description: "Report output format (yaml, json, csv)" +); + +var reportVerbosityOption = new Option( + aliases: new[] { "--report-verbosity", "-rv" }, + getDefaultValue: () => "basic", + description: "Report detail level (basic, medium, verbose)" +); + +// Add global options +rootCommand.AddOption(configOption); +rootCommand.AddOption(connectionStringOption); +rootCommand.AddOption(clientIdOption); +rootCommand.AddOption(clientSecretOption); +rootCommand.AddOption(tenantIdOption); +rootCommand.AddOption(environmentUrlOption); +rootCommand.AddOption(verbosityOption); +rootCommand.AddOption(outputPathOption); +rootCommand.AddOption(reportFormatOption); +rootCommand.AddOption(reportVerbosityOption); + +// Index command +var indexCommand = new Command("index", "Build an index of solutions, components, and their layers"); +indexCommand.SetHandler(async (context) => +{ + var cli = CreateCli(context); + await cli.ExecuteIndexAsync(context.GetCancellationToken()); +}); + +// Run command - execute all reports from config +var runCommand = new Command("run", "Execute all reports defined in the configuration file"); +runCommand.SetHandler(async (context) => +{ + var cli = CreateCli(context); + await cli.ExecuteReportsAsync(context.GetCancellationToken()); +}); + +// Export command - export configuration +var exportCommand = new Command("export", "Export current report configuration to YAML"); +var exportFileOption = new Option( + aliases: new[] { "--file", "-f" }, + description: "Output file path for exported configuration" +); +exportCommand.AddOption(exportFileOption); +exportCommand.SetHandler(async (context) => +{ + var cli = CreateCli(context); + var exportFile = context.ParseResult.GetValueForOption(exportFileOption); + await cli.ExportConfigAsync(exportFile, context.GetCancellationToken()); +}); + +// Import command - import configuration +var importCommand = new Command("import", "Import report configuration from YAML"); +var importFileOption = new Option( + aliases: new[] { "--file", "-f" }, + description: "Input file path for configuration to import" +); +importFileOption.IsRequired = true; +importCommand.AddOption(importFileOption); +importCommand.SetHandler(async (context) => +{ + var cli = CreateCli(context); + var importFile = context.ParseResult.GetValueForOption(importFileOption)!; + await cli.ImportConfigAsync(importFile, context.GetCancellationToken()); +}); + +rootCommand.AddCommand(indexCommand); +rootCommand.AddCommand(runCommand); +rootCommand.AddCommand(exportCommand); +rootCommand.AddCommand(importCommand); + +return await rootCommand.InvokeAsync(args); + +static SolutionAnalyzerCli CreateCli(InvocationContext context) +{ + var config = context.ParseResult.GetValueForOption(configOption)!; + var connectionString = context.ParseResult.GetValueForOption(connectionStringOption); + var clientId = context.ParseResult.GetValueForOption(clientIdOption); + var clientSecret = context.ParseResult.GetValueForOption(clientSecretOption); + var tenantId = context.ParseResult.GetValueForOption(tenantIdOption); + var environmentUrl = context.ParseResult.GetValueForOption(environmentUrlOption); + var verbosity = context.ParseResult.GetValueForOption(verbosityOption)!; + var output = context.ParseResult.GetValueForOption(outputPathOption)!; + var reportFormat = context.ParseResult.GetValueForOption(reportFormatOption)!; + var reportVerbosity = context.ParseResult.GetValueForOption(reportVerbosityOption)!; + + var logLevel = verbosity.ToLowerInvariant() switch + { + "quiet" => LogLevel.Error, + "minimal" => LogLevel.Warning, + "normal" => LogLevel.Information, + "detailed" => LogLevel.Debug, + "diagnostic" => LogLevel.Trace, + _ => LogLevel.Information + }; + + return new SolutionAnalyzerCli( + config, + connectionString, + clientId, + clientSecret, + tenantId, + environmentUrl, + logLevel, + output, + reportFormat, + reportVerbosity); +} diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs new file mode 100644 index 0000000..09f58c1 --- /dev/null +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs @@ -0,0 +1,241 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using Ddk.SolutionLayerAnalyzer; +using Ddk.SolutionLayerAnalyzer.Models; +using Ddk.SolutionLayerAnalyzer.DTOs; + +namespace DataverseDevKit.SolutionAnalyzer.CLI; + +/// +/// Main CLI implementation for the Solution Analyzer +/// +public class SolutionAnalyzerCli +{ + private readonly FileInfo _configFile; + private readonly string? _connectionString; + private readonly string? _clientId; + private readonly string? _clientSecret; + private readonly string? _tenantId; + private readonly string? _environmentUrl; + private readonly LogLevel _logLevel; + private readonly DirectoryInfo _outputDirectory; + private readonly string _reportFormat; + private readonly string _reportVerbosity; + private readonly ILogger _logger; + private readonly string _logFilePath; + + public SolutionAnalyzerCli( + FileInfo configFile, + string? connectionString, + string? clientId, + string? clientSecret, + string? tenantId, + string? environmentUrl, + LogLevel logLevel, + DirectoryInfo outputDirectory, + string reportFormat, + string reportVerbosity) + { + _configFile = configFile; + _connectionString = connectionString; + _clientId = clientId; + _clientSecret = clientSecret; + _tenantId = tenantId; + _environmentUrl = environmentUrl; + _logLevel = logLevel; + _outputDirectory = outputDirectory; + _reportFormat = reportFormat; + _reportVerbosity = reportVerbosity; + + // Ensure output directory exists + if (!_outputDirectory.Exists) + { + _outputDirectory.Create(); + } + + // Setup file logging + _logFilePath = Path.Combine(_outputDirectory.FullName, $"cli-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log"); + var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddProvider(new FileLoggerProvider(_logFilePath, logLevel)); + builder.AddConsole(); + }); + _logger = loggerFactory.CreateLogger(); + + _logger.LogInformation("Solution Analyzer CLI initialized"); + _logger.LogInformation("Config file: {ConfigFile}", _configFile.FullName); + _logger.LogInformation("Output directory: {OutputDirectory}", _outputDirectory.FullName); + _logger.LogInformation("Log file: {LogFile}", _logFilePath); + } + + public async Task ExecuteIndexAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("=== Starting Index Operation ==="); + + try + { + // Load configuration + var config = await LoadConfigurationAsync(cancellationToken); + _logger.LogInformation("Configuration loaded: {SourceCount} sources, {TargetCount} targets", + config.SourceSolutions.Count, config.TargetSolutions.Count); + + // TODO: Initialize plugin and execute index command + // This requires setting up the plugin context, service client factory, etc. + // For now, log what would be done + _logger.LogInformation("Would index solutions: {Sources} -> {Targets}", + string.Join(", ", config.SourceSolutions), + string.Join(", ", config.TargetSolutions)); + + Console.WriteLine("✓ Index operation completed successfully"); + Console.WriteLine($" Log file: {_logFilePath}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Index operation failed"); + Console.Error.WriteLine($"✗ Index operation failed: {ex.Message}"); + Console.Error.WriteLine($" See log file for details: {_logFilePath}"); + throw; + } + } + + public async Task ExecuteReportsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("=== Starting Report Execution ==="); + + try + { + // Load configuration + var config = await LoadConfigurationAsync(cancellationToken); + + var totalReports = config.ReportGroups.Sum(g => g.Reports.Count) + config.UngroupedReports.Count; + _logger.LogInformation("Loaded {GroupCount} groups with {ReportCount} total reports", + config.ReportGroups.Count, totalReports); + + // TODO: Execute all reports via plugin + // For now, log what would be done + foreach (var group in config.ReportGroups) + { + _logger.LogInformation("Group: {GroupName} ({Count} reports)", group.Name, group.Reports.Count); + foreach (var report in group.Reports) + { + _logger.LogInformation(" - {ReportName} (Severity: {Severity})", report.Name, report.Severity); + } + } + + // Generate summary report + var reportFileName = $"report-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{GetFileExtension(_reportFormat)}"; + var reportPath = Path.Combine(_outputDirectory.FullName, reportFileName); + + _logger.LogInformation("Report would be generated at: {ReportPath}", reportPath); + + Console.WriteLine("✓ Report execution completed successfully"); + Console.WriteLine($" Reports executed: {totalReports}"); + Console.WriteLine($" Output file: {reportPath}"); + Console.WriteLine($" Log file: {_logFilePath}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Report execution failed"); + Console.Error.WriteLine($"✗ Report execution failed: {ex.Message}"); + Console.Error.WriteLine($" See log file for details: {_logFilePath}"); + throw; + } + } + + public async Task ExportConfigAsync(FileInfo? outputFile, CancellationToken cancellationToken) + { + _logger.LogInformation("=== Starting Config Export ==="); + + try + { + // TODO: Export current configuration from plugin + // For now, create a sample config + var config = new AnalyzerConfig + { + SourceSolutions = new List { "CoreSolution" }, + TargetSolutions = new List { "Project1", "Project2" }, + ReportGroups = new List(), + UngroupedReports = new List() + }; + + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var yaml = serializer.Serialize(config); + + var exportPath = outputFile?.FullName ?? Path.Combine(_outputDirectory.FullName, "config-export.yaml"); + await File.WriteAllTextAsync(exportPath, yaml, cancellationToken); + + _logger.LogInformation("Configuration exported to: {ExportPath}", exportPath); + Console.WriteLine("✓ Configuration exported successfully"); + Console.WriteLine($" Output file: {exportPath}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Config export failed"); + Console.Error.WriteLine($"✗ Config export failed: {ex.Message}"); + throw; + } + } + + public async Task ImportConfigAsync(FileInfo inputFile, CancellationToken cancellationToken) + { + _logger.LogInformation("=== Starting Config Import ==="); + + try + { + if (!inputFile.Exists) + { + throw new FileNotFoundException($"Configuration file not found: {inputFile.FullName}"); + } + + var yaml = await File.ReadAllTextAsync(inputFile.FullName, cancellationToken); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var config = deserializer.Deserialize(yaml); + + var totalReports = config.ReportGroups.Sum(g => g.Reports.Count) + config.UngroupedReports.Count; + _logger.LogInformation("Imported configuration with {GroupCount} groups and {ReportCount} reports", + config.ReportGroups.Count, totalReports); + + // TODO: Import configuration to plugin + Console.WriteLine("✓ Configuration imported successfully"); + Console.WriteLine($" Groups: {config.ReportGroups.Count}"); + Console.WriteLine($" Reports: {totalReports}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Config import failed"); + Console.Error.WriteLine($"✗ Config import failed: {ex.Message}"); + throw; + } + } + + private async Task LoadConfigurationAsync(CancellationToken cancellationToken) + { + if (!_configFile.Exists) + { + throw new FileNotFoundException($"Configuration file not found: {_configFile.FullName}"); + } + + var yaml = await File.ReadAllTextAsync(_configFile.FullName, cancellationToken); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + return deserializer.Deserialize(yaml); + } + + private static string GetFileExtension(string format) + { + return format.ToLowerInvariant() switch + { + "json" => "json", + "csv" => "csv", + _ => "yaml" + }; + } +} diff --git a/src/cli/solution-analyzer/README.md b/src/cli/solution-analyzer/README.md new file mode 100644 index 0000000..aef6f3d --- /dev/null +++ b/src/cli/solution-analyzer/README.md @@ -0,0 +1,260 @@ +# Dataverse DevKit Solution Analyzer CLI + +Enterprise-ready command-line tool for analyzing Dataverse solution component layering and generating detailed reports. + +## Features + +- **YAML Configuration**: Define analysis settings and reports in human-readable YAML files +- **Multiple Output Formats**: Generate reports in YAML, JSON, or CSV for Excel processing +- **Report Verbosity Levels**: Control detail level (basic, medium, verbose) +- **Flexible Authentication**: Support for interactive login, connection strings, and service principals +- **Enterprise Reporting**: Define severity levels, actions, and group reports for organizational needs +- **File Logging**: Detailed logs for troubleshooting and audit trails + +## Installation + +Build the CLI tool: + +```bash +cd src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI +dotnet build -c Release +``` + +The executable will be available at `bin/Release/net10.0/ddk-solution-analyzer` + +## Quick Start + +1. **Create a configuration file** (see `example-config.yaml`): + +```yaml +sourceSolutions: + - CoreSolution +targetSolutions: + - Project1 + - Project2 +reportGroups: + - name: Project 1 Analysis + reports: + - name: Empty Layers + severity: Information + # ... query definition +``` + +2. **Index your solutions**: + +```bash +ddk-solution-analyzer index \ + --config analyzer-config.yaml \ + --environment-url https://yourorg.crm.dynamics.com \ + --output ./reports +``` + +3. **Run all reports**: + +```bash +ddk-solution-analyzer run \ + --config analyzer-config.yaml \ + --environment-url https://yourorg.crm.dynamics.com \ + --format yaml \ + --report-verbosity medium \ + --output ./reports +``` + +## Commands + +### `index` + +Build an index of solutions, components, and their layers. + +```bash +ddk-solution-analyzer index \ + --config \ + --environment-url \ + [--verbosity ] \ + [--output ] +``` + +### `run` + +Execute all reports defined in the configuration file. + +```bash +ddk-solution-analyzer run \ + --config \ + --environment-url \ + [--format yaml|json|csv] \ + [--report-verbosity basic|medium|verbose] \ + [--verbosity ] \ + [--output ] +``` + +### `export` + +Export current report configuration to YAML. + +```bash +ddk-solution-analyzer export \ + --config \ + --environment-url \ + [--file ] \ + [--output ] +``` + +### `import` + +Import report configuration from YAML. + +```bash +ddk-solution-analyzer import \ + --config \ + --environment-url \ + --file \ + [--output ] +``` + +## Global Options + +### Authentication Options + +**Connection String** (easiest for testing): +```bash +--connection-string "AuthType=OAuth;Username=user@domain.com;..." +``` + +**Service Principal** (recommended for automation): +```bash +--client-id \ +--client-secret \ +--tenant-id \ +--environment-url +``` + +**Interactive** (default): +```bash +--environment-url +``` + +### Output Options + +- `--output, -o`: Output directory for reports and logs (default: current directory) +- `--format, -f`: Report format - yaml, json, or csv (default: yaml) +- `--report-verbosity, -rv`: Report detail level - basic, medium, or verbose (default: basic) + +### Logging Options + +- `--verbosity, -v`: Log verbosity - quiet, minimal, normal, detailed, or diagnostic (default: normal) + +## Report Verbosity Levels + +### Basic +- Component ID, type, logical name, display name +- List of solutions containing the component +- Minimal detail for quick overview + +### Medium +- All basic information +- List of changed attributes per layer +- Good balance for most scenarios + +### Verbose +- All medium information +- Full attribute change details (old/new values) +- Maximum detail for deep analysis + +## Output Formats + +### YAML +Human-readable, structured format ideal for reviewing reports manually. + +### JSON +Machine-readable format perfect for integration with other tools and CI/CD pipelines. + +### CSV +Flat format optimized for Excel processing and pivot tables. + +## Configuration File Structure + +```yaml +# Global settings +sourceSolutions: + - CoreSolution +targetSolutions: + - Project1 + - Project2 + +# Optional: Limit to specific component types +componentTypes: + - 1 # Entity + - 24 # SystemForm + - 26 # SavedQuery + +# Organized reports +reportGroups: + - name: Group Name + displayOrder: 1 + reports: + - name: Report Name + description: What this report checks + severity: Information|Warning|Critical + recommendedAction: What to do with findings + displayOrder: 1 + queryJson: '{ ... filter query ... }' + +# Reports not in any group +ungroupedReports: + - name: Standalone Report + # ... same fields as above +``` + +## Examples + +### CI/CD Integration + +```bash +#!/bin/bash +# Run solution analysis in CI/CD pipeline + +ddk-solution-analyzer run \ + --config solution-analysis.yaml \ + --client-id $AZURE_CLIENT_ID \ + --client-secret $AZURE_CLIENT_SECRET \ + --tenant-id $AZURE_TENANT_ID \ + --environment-url $DATAVERSE_URL \ + --format csv \ + --report-verbosity medium \ + --verbosity minimal \ + --output ./build/reports + +# Check exit code +if [ $? -ne 0 ]; then + echo "Analysis failed!" + exit 1 +fi + +echo "Analysis completed successfully" +``` + +### Generate Multiple Format Reports + +```bash +# YAML for manual review +ddk-solution-analyzer run --config config.yaml -f yaml -o ./reports/yaml + +# JSON for automation +ddk-solution-analyzer run --config config.yaml -f json -o ./reports/json + +# CSV for Excel +ddk-solution-analyzer run --config config.yaml -f csv -o ./reports/csv +``` + +## Troubleshooting + +- **Log files**: Check the CLI log file in the output directory for detailed execution logs +- **Verbosity**: Increase verbosity with `--verbosity diagnostic` for maximum detail +- **Authentication**: Verify credentials and permissions if connection fails + +## See Also + +- [Example Configuration](example-config.yaml) +- [Plugin Documentation](../../plugins/solution-layer-analyzer/README.md) +- [Query Syntax Reference](../../plugins/solution-layer-analyzer/docs/query-syntax.md) diff --git a/src/cli/solution-analyzer/example-config.yaml b/src/cli/solution-analyzer/example-config.yaml new file mode 100644 index 0000000..600cdc3 --- /dev/null +++ b/src/cli/solution-analyzer/example-config.yaml @@ -0,0 +1,53 @@ +# Solution Layer Analyzer Configuration +# This file defines the global settings and reports for analyzing Dataverse solutions + +# Source solutions to analyze (typically your core/base solutions) +sourceSolutions: + - CoreSolution + +# Target solutions to compare against (typically your project-specific solutions) +targetSolutions: + - Project1 + - Project2 + +# Optional: Component types to include (if omitted, all types are included) +# componentTypes: +# - 1 # Entity +# - 24 # SystemForm +# - 26 # SavedQuery + +# Report groups organize related reports together +reportGroups: + - name: Project 1 Analysis + displayOrder: 1 + reports: + - name: Empty Layers that can be removed + description: Identifies layers with no actual changes that can be safely removed + severity: Information + recommendedAction: Remove empty layer from solution + displayOrder: 1 + queryJson: '{"type":"AND","children":[{"type":"ORDER_FLEX","solutions":["CoreSolution","Project1"]},{"type":"LAYER_ATTRIBUTE_QUERY","solution":"Project1","condition":{"type":"NOT","child":{"type":"LAYER_ATTRIBUTE_DIFF","solution":"Project1","compareTo":"CoreSolution"}}}]}' + + - name: Form or View layers that may need consolidation + description: Forms/Views customized in Project1 but not in other projects + severity: Warning + recommendedAction: Consider moving the customization to Core solution for future harmonization + displayOrder: 2 + queryJson: '{"type":"AND","children":[{"type":"OR","children":[{"type":"ATTRIBUTE_QUERY","attribute":"ComponentType","operator":"EQUALS","value":"SavedQuery"},{"type":"ATTRIBUTE_QUERY","attribute":"ComponentType","operator":"EQUALS","value":"SystemForm"}]},{"type":"ORDER_FLEX","solutions":["CoreSolution","Project1"]},{"type":"NOT","child":{"type":"HAS","solutions":["Project2"]}},{"type":"LAYER_ATTRIBUTE_QUERY","solution":"Project1","condition":{"type":"LAYER_ATTRIBUTE_DIFF","solution":"Project1","compareTo":"CoreSolution"}}]}' + + - name: Form or View conflicts between projects + description: Forms/Views customized in multiple projects creating conflicts + severity: Critical + recommendedAction: Immediate action required by all projects overriding the view or form + displayOrder: 3 + queryJson: '{"type":"AND","children":[{"type":"OR","children":[{"type":"ATTRIBUTE_QUERY","attribute":"ComponentType","operator":"EQUALS","value":"SavedQuery"},{"type":"ATTRIBUTE_QUERY","attribute":"ComponentType","operator":"EQUALS","value":"SystemForm"}]},{"type":"ORDER_FLEX","solutions":["CoreSolution","Project1"]},{"type":"HAS","solutions":["Project2"]},{"type":"LAYER_ATTRIBUTE_QUERY","solution":"Project1","condition":{"type":"LAYER_ATTRIBUTE_DIFF","solution":"Project1","compareTo":"CoreSolution"}}]}' + + - name: Non-UI component conflicts + description: Non-UI components (entities, attributes, etc.) with conflicting layers + severity: Critical + recommendedAction: Immediate action required, potential layer on shared concerns + displayOrder: 4 + queryJson: '{"type":"AND","children":[{"type":"NOT","child":{"type":"OR","children":[{"type":"ATTRIBUTE_QUERY","attribute":"ComponentType","operator":"EQUALS","value":"SavedQuery"},{"type":"ATTRIBUTE_QUERY","attribute":"ComponentType","operator":"EQUALS","value":"SystemForm"}]}},{"type":"ORDER_FLEX","solutions":["CoreSolution","Project1"]},{"type":"LAYER_ATTRIBUTE_QUERY","solution":"Project1","condition":{"type":"LAYER_ATTRIBUTE_DIFF","solution":"Project1","compareTo":"CoreSolution"}}]}' + +# Ungrouped reports (not part of any group) +ungroupedReports: [] From 208faf6f224cbd5a4898f8204f9cb22c28ffd729 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:28:10 +0000 Subject: [PATCH 04/14] Add comprehensive implementation summary documentation Co-authored-by: mathis-m <11584315+mathis-m@users.noreply.github.com> --- IMPLEMENTATION_SUMMARY.md | 314 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 IMPLEMENTATION_SUMMARY.md diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..f436199 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,314 @@ +# Enterprise Reporting and CLI Implementation Summary + +## Overview + +This implementation adds comprehensive enterprise-ready reporting and monitoring capabilities to the DataverseDevKit Solution Layer Analyzer, including a command-line interface (CLI) for automation and CI/CD integration. + +## What Was Implemented + +### 1. Backend Models and Database Schema + +#### New Models +- **Report**: Represents a saved report configuration with query, severity, recommended actions +- **ReportGroup**: Organizes reports into logical groups +- **AnalyzerConfig**: Global configuration for analyzer including source/target solutions and reports +- **ReportSeverity**: Enum for severity levels (Information, Warning, Critical) +- **ReportVerbosity**: Enum for output detail levels (Basic, Medium, Verbose) +- **ReportOutputFormat**: Enum for output formats (YAML, JSON, CSV) + +#### Database Changes +- Added `Reports` table with columns for name, description, group, severity, action, query, display order +- Added `ReportGroups` table for organizing reports +- Configured Entity Framework relationships and indexes +- Support for automatic schema migration via EF Core + +### 2. Report Management Service + +Created `ReportService` with comprehensive CRUD operations: +- **Save/Update/Delete Reports**: Full lifecycle management +- **Duplicate Reports**: Clone existing reports with modifications +- **Reorder Reports**: Change display order and grouping +- **Execute Reports**: Run saved queries and return results +- **Group Management**: Create, update, delete, and reorder report groups +- **Export/Import Configuration**: YAML-based config persistence + +#### Serialization Support +- **YAML**: Using YamlDotNet for human-readable configs +- **JSON**: For machine-readable integration +- **CSV**: Custom CSV helper for Excel processing + +The CSV output is optimized for different verbosity levels: +- Basic: One row per component +- Medium: One row per layer with changed attributes list +- Verbose: One row per attribute change with old/new values + +### 3. Plugin Command Extensions + +Added 14 new commands to the Solution Layer Analyzer plugin: + +#### Report Commands +- `saveReport`: Save a query/filter as a report +- `updateReport`: Modify an existing report +- `deleteReport`: Remove a report +- `duplicateReport`: Clone a report +- `listReports`: Get all reports organized by groups +- `executeReport`: Run a saved report +- `reorderReports`: Change report ordering and grouping + +#### Group Commands +- `createReportGroup`: Create a new group +- `updateReportGroup`: Modify a group +- `deleteReportGroup`: Remove a group +- `reorderReportGroups`: Change group ordering + +#### Config Commands +- `exportConfig`: Export configuration to YAML +- `importConfig`: Import configuration from YAML +- `generateReportOutput`: Generate detailed reports in YAML/JSON/CSV + +### 4. CLI Application + +Created a complete .NET Console application (`DataverseDevKit.SolutionAnalyzer.CLI`) with: + +#### Features +- **System.CommandLine**: Modern command-line parsing +- **File Logging**: Dedicated log files per execution +- **Console Logging**: Real-time feedback with success/error indicators +- **YAML Configuration**: Human-readable config files +- **Multiple Output Formats**: YAML, JSON, CSV +- **Flexible Verbosity**: Control detail level for both CLI and reports + +#### Commands +- `index`: Build solution component index +- `run`: Execute all configured reports +- `export`: Export current configuration +- `import`: Import configuration from file + +#### Authentication Support (Structure Ready) +- Connection string +- Interactive OAuth (placeholder) +- Service principal with client credentials (placeholder) + +## Configuration File Structure + +The YAML configuration file structure: + +```yaml +sourceSolutions: + - CoreSolution +targetSolutions: + - Project1 + - Project2 +componentTypes: # Optional + - 1 # Entity + - 24 # SystemForm +reportGroups: + - name: Group Name + displayOrder: 1 + reports: + - name: Report Name + description: Description + severity: Information|Warning|Critical + recommendedAction: Action to take + displayOrder: 1 + queryJson: '{ filter query JSON }' +ungroupedReports: + - name: Standalone Report + # same structure as above +``` + +## Example Report Scenarios + +The implementation supports the scenarios described in the requirements: + +### 1. Empty Layers Detection +```yaml +name: Empty Layers that can be removed +severity: Information +action: Remove empty layer +``` +Identifies layers with no actual changes that can be safely removed. + +### 2. Consolidation Opportunities +```yaml +name: Form or View layers that may need consolidation +severity: Warning +action: Consider moving to Core solution +``` +Finds customizations that should potentially move to the core solution. + +### 3. Critical Conflicts +```yaml +name: Form or View conflicts between projects +severity: Critical +action: Immediate action required +``` +Detects conflicting customizations across multiple projects. + +### 4. Shared Concerns +```yaml +name: Non-UI component conflicts +severity: Critical +action: Review potential layer on shared concerns +``` +Identifies conflicts in entities, attributes, and other shared components. + +## Output Examples + +### YAML Output (Medium Verbosity) +```yaml +generatedAt: 2026-02-01T00:00:00Z +connectionId: env-12345 +verbosity: Medium +reports: + - name: Empty Layers + group: Project 1 + severity: Information + totalMatches: 5 + components: + - componentId: guid + componentType: 24 + logicalName: contact_form + layers: + - solutionName: Project1 + ordinal: 1 + changedAttributes: + - formXml + - displayName +``` + +### CSV Output (Basic Verbosity) +``` +Report Name,Group,Severity,Recommended Action,Component ID,Component Type,Logical Name,Display Name,Solutions,Make Portal URL +Empty Layers,Project 1,Information,Remove empty layer,guid,24,contact_form,Contact Form,CoreSolution; Project1,https://make.powerapps.com/... +``` + +## File Structure + +``` +src/ +├── cli/ +│ └── solution-analyzer/ +│ ├── DataverseDevKit.SolutionAnalyzer.CLI/ +│ │ ├── Program.cs +│ │ ├── SolutionAnalyzerCli.cs +│ │ ├── FileLogger.cs +│ │ └── DataverseDevKit.SolutionAnalyzer.CLI.csproj +│ ├── README.md +│ └── example-config.yaml +└── plugins/ + └── solution-layer-analyzer/ + └── src/ + ├── Models/ + │ ├── Report.cs + │ ├── ReportGroup.cs + │ ├── ReportSeverity.cs + │ └── AnalyzerConfig.cs + ├── Services/ + │ ├── ReportService.cs + │ └── CsvHelper.cs + ├── DTOs/ + │ └── ReportDtos.cs + ├── Data/ + │ └── AnalyzerDbContext.cs (updated) + └── SolutionLayerAnalyzerPlugin.cs (updated) +``` + +## Usage Examples + +### Index Solutions +```bash +ddk-solution-analyzer index \ + --config analyzer-config.yaml \ + --environment-url https://yourorg.crm.dynamics.com \ + --output ./reports +``` + +### Run All Reports +```bash +ddk-solution-analyzer run \ + --config analyzer-config.yaml \ + --environment-url https://yourorg.crm.dynamics.com \ + --format csv \ + --report-verbosity medium \ + --output ./reports +``` + +### CI/CD Integration +```bash +#!/bin/bash +ddk-solution-analyzer run \ + --config solution-analysis.yaml \ + --client-id $AZURE_CLIENT_ID \ + --client-secret $AZURE_CLIENT_SECRET \ + --tenant-id $AZURE_TENANT_ID \ + --environment-url $DATAVERSE_URL \ + --format json \ + --verbosity minimal \ + --output ./build/reports +``` + +## Next Steps + +To complete the implementation, the following items remain: + +### Authentication Integration +- Implement interactive OAuth flow in CLI +- Implement service principal authentication +- Integrate with Dataverse service client factory + +### Plugin Host Integration +- Wire up CLI to directly call plugin methods +- Setup plugin context and service providers +- Handle plugin lifecycle (initialize, execute, dispose) + +### Frontend UI (Phase 5) +- Create Reports tab in web UI +- Implement "Save as Report" from analysis view +- Add report editor and management UI +- Drag-and-drop reordering +- Import/export UI + +### Testing (Phase 6) +- Unit tests for report models +- Unit tests for ReportService +- Integration tests for CLI +- End-to-end tests + +## Benefits + +### For Development Teams +- **Early Detection**: Catch layer conflicts and issues early in development +- **Automated Monitoring**: Run reports in CI/CD pipelines +- **Clear Guidelines**: Severity levels and recommended actions guide developers + +### For Enterprise Organizations +- **Standardization**: Enforce organizational layering standards +- **Audit Trail**: File logs and report history +- **Excel Integration**: CSV export for analysis and reporting +- **Flexibility**: YAML configs easy to version control and share + +### For DevOps +- **Automation**: CLI tool integrates into existing pipelines +- **Multiple Formats**: JSON for tools, CSV for humans, YAML for config +- **Configurable**: Adjust verbosity and formats per use case +- **Scalable**: Process multiple environments with same config + +## Technical Highlights + +1. **Clean Architecture**: Separation of concerns between models, services, and presentation +2. **Entity Framework Core**: Modern ORM with SQLite for local persistence +3. **YAML Configuration**: Human-readable, version-control friendly +4. **Multiple Serializers**: YamlDotNet, System.Text.Json, custom CSV +5. **Extensible**: Easy to add new commands, output formats, or report types +6. **Type-Safe**: Strong typing throughout with nullable reference types +7. **Async/Await**: Modern async patterns for better performance +8. **Logging**: Structured logging with Microsoft.Extensions.Logging + +## Conclusion + +This implementation provides a solid foundation for enterprise-grade solution layer analysis and monitoring. The combination of a powerful plugin backend with a flexible CLI tool enables both interactive usage and automation scenarios. + +The YAML-based configuration makes it easy to define, share, and version control reporting rules, while the multiple output formats ensure compatibility with various tools and workflows. + +The modular design allows for easy extension and customization to meet specific organizational needs. From ab0646f7d2d6a0f8e7cebdac958be9f0a00b63f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:39:44 +0000 Subject: [PATCH 05/14] Refactor CLI to directly call plugin without host dependencies Co-authored-by: mathis-m <11584315+mathis-m@users.noreply.github.com> --- .../CliPluginContext.cs | 47 ++ .../CliServiceClientFactory.cs | 54 +++ ...ataverseDevKit.SolutionAnalyzer.CLI.csproj | 1 + .../Program.cs | 95 ++-- .../ReportExecutor.cs | 452 ++++++++++++++++++ .../SolutionAnalyzerCli.cs | 241 ---------- src/cli/solution-analyzer/README.md | 306 ++++-------- 7 files changed, 672 insertions(+), 524 deletions(-) create mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliPluginContext.cs create mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliServiceClientFactory.cs create mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/ReportExecutor.cs delete mode 100644 src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliPluginContext.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliPluginContext.cs new file mode 100644 index 0000000..23cd842 --- /dev/null +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliPluginContext.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Logging; +using DataverseDevKit.Core.Abstractions; +using DataverseDevKit.Core.Models; + +namespace DataverseDevKit.SolutionAnalyzer.CLI; + +/// +/// Simple plugin context implementation for CLI usage +/// +internal class CliPluginContext : IPluginContext +{ + private readonly ILogger _logger; + private readonly string _storagePath; + private readonly IServiceClientFactory _serviceClientFactory; + + public CliPluginContext(ILogger logger, string storagePath, IServiceClientFactory serviceClientFactory) + { + _logger = logger; + _storagePath = storagePath; + _serviceClientFactory = serviceClientFactory; + } + + public ILogger Logger => _logger; + + public string StoragePath => _storagePath; + + public IServiceClientFactory ServiceClientFactory => _serviceClientFactory; + + public void EmitEvent(PluginEvent pluginEvent) + { + // In CLI mode, we just log events instead of emitting them to a host + _logger.LogDebug("Plugin Event: {EventType} - {Message}", + pluginEvent.Type, pluginEvent.Message); + } + + public Task GetConfigAsync(string key, CancellationToken cancellationToken = default) + { + // CLI doesn't use persistent config + return Task.FromResult(null); + } + + public Task SetConfigAsync(string key, string value, CancellationToken cancellationToken = default) + { + // CLI doesn't use persistent config + return Task.CompletedTask; + } +} diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliServiceClientFactory.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliServiceClientFactory.cs new file mode 100644 index 0000000..37789bb --- /dev/null +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/CliServiceClientFactory.cs @@ -0,0 +1,54 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using DataverseDevKit.Core.Abstractions; + +namespace DataverseDevKit.SolutionAnalyzer.CLI; + +/// +/// Simple service client factory for CLI usage +/// +internal class CliServiceClientFactory : IServiceClientFactory +{ + private readonly string _environmentUrl; + private readonly string? _connectionString; + private readonly string? _clientId; + private readonly string? _clientSecret; + private readonly string? _tenantId; + + public CliServiceClientFactory( + string environmentUrl, + string? connectionString = null, + string? clientId = null, + string? clientSecret = null, + string? tenantId = null) + { + _environmentUrl = environmentUrl; + _connectionString = connectionString; + _clientId = clientId; + _clientSecret = clientSecret; + _tenantId = tenantId; + } + + public ServiceClient GetServiceClient(string connectionId) + { + // Build connection string based on provided auth method + string connString; + + if (!string.IsNullOrEmpty(_connectionString)) + { + // Use provided connection string + connString = _connectionString; + } + else if (!string.IsNullOrEmpty(_clientId) && !string.IsNullOrEmpty(_clientSecret) && !string.IsNullOrEmpty(_tenantId)) + { + // Service principal authentication + connString = $"AuthType=ClientSecret;Url={_environmentUrl};ClientId={_clientId};ClientSecret={_clientSecret};TenantId={_tenantId}"; + } + else + { + // Interactive authentication + connString = $"AuthType=OAuth;Url={_environmentUrl};LoginPrompt=Auto;RedirectUri=http://localhost;RequireNewInstance=True"; + } + + return new ServiceClient(connString); + } +} diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj index c2b709a..31fe9f2 100644 --- a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/DataverseDevKit.SolutionAnalyzer.CLI.csproj @@ -14,6 +14,7 @@ + diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs index 22dabeb..2341476 100644 --- a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/Program.cs @@ -4,7 +4,7 @@ using DataverseDevKit.SolutionAnalyzer.CLI; // Root command -var rootCommand = new RootCommand("Dataverse DevKit Solution Layer Analyzer CLI - Enterprise reporting tool for Dataverse solution analysis"); +var rootCommand = new RootCommand("Dataverse DevKit Solution Layer Analyzer CLI - Execute solution integrity reports for CI/CD monitoring"); // Global options var configOption = new Option( @@ -37,29 +37,34 @@ aliases: new[] { "--environment-url", "-e" }, description: "Dataverse environment URL" ); +environmentUrlOption.IsRequired = true; var verbosityOption = new Option( aliases: new[] { "--verbosity", "-v" }, getDefaultValue: () => "normal", - description: "Log verbosity level (quiet, minimal, normal, detailed, diagnostic)" + description: "Console log verbosity (quiet, minimal, normal, detailed)" ); var outputPathOption = new Option( aliases: new[] { "--output", "-o" }, getDefaultValue: () => new DirectoryInfo("."), - description: "Output directory for reports and logs" + description: "Output directory for reports and plugin logs" ); -var reportFormatOption = new Option( +var formatOption = new Option( aliases: new[] { "--format", "-f" }, getDefaultValue: () => "yaml", description: "Report output format (yaml, json, csv)" ); -var reportVerbosityOption = new Option( - aliases: new[] { "--report-verbosity", "-rv" }, - getDefaultValue: () => "basic", - description: "Report detail level (basic, medium, verbose)" +var failOnSeverityOption = new Option( + aliases: new[] { "--fail-on-severity" }, + description: "Fail pipeline if findings of this severity or higher (critical, warning, information)" +); + +var maxFindingsOption = new Option( + aliases: new[] { "--max-findings" }, + description: "Maximum number of findings allowed before failing" ); // Add global options @@ -71,73 +76,33 @@ rootCommand.AddOption(environmentUrlOption); rootCommand.AddOption(verbosityOption); rootCommand.AddOption(outputPathOption); -rootCommand.AddOption(reportFormatOption); -rootCommand.AddOption(reportVerbosityOption); - -// Index command -var indexCommand = new Command("index", "Build an index of solutions, components, and their layers"); -indexCommand.SetHandler(async (context) => -{ - var cli = CreateCli(context); - await cli.ExecuteIndexAsync(context.GetCancellationToken()); -}); +rootCommand.AddOption(formatOption); +rootCommand.AddOption(failOnSeverityOption); +rootCommand.AddOption(maxFindingsOption); -// Run command - execute all reports from config -var runCommand = new Command("run", "Execute all reports defined in the configuration file"); -runCommand.SetHandler(async (context) => +// Main execution - run all reports +rootCommand.SetHandler(async (context) => { var cli = CreateCli(context); - await cli.ExecuteReportsAsync(context.GetCancellationToken()); + var exitCode = await cli.ExecuteReportsAsync(context.GetCancellationToken()); + context.ExitCode = exitCode; }); -// Export command - export configuration -var exportCommand = new Command("export", "Export current report configuration to YAML"); -var exportFileOption = new Option( - aliases: new[] { "--file", "-f" }, - description: "Output file path for exported configuration" -); -exportCommand.AddOption(exportFileOption); -exportCommand.SetHandler(async (context) => -{ - var cli = CreateCli(context); - var exportFile = context.ParseResult.GetValueForOption(exportFileOption); - await cli.ExportConfigAsync(exportFile, context.GetCancellationToken()); -}); - -// Import command - import configuration -var importCommand = new Command("import", "Import report configuration from YAML"); -var importFileOption = new Option( - aliases: new[] { "--file", "-f" }, - description: "Input file path for configuration to import" -); -importFileOption.IsRequired = true; -importCommand.AddOption(importFileOption); -importCommand.SetHandler(async (context) => -{ - var cli = CreateCli(context); - var importFile = context.ParseResult.GetValueForOption(importFileOption)!; - await cli.ImportConfigAsync(importFile, context.GetCancellationToken()); -}); - -rootCommand.AddCommand(indexCommand); -rootCommand.AddCommand(runCommand); -rootCommand.AddCommand(exportCommand); -rootCommand.AddCommand(importCommand); - return await rootCommand.InvokeAsync(args); -static SolutionAnalyzerCli CreateCli(InvocationContext context) +static ReportExecutor CreateCli(InvocationContext context) { var config = context.ParseResult.GetValueForOption(configOption)!; var connectionString = context.ParseResult.GetValueForOption(connectionStringOption); var clientId = context.ParseResult.GetValueForOption(clientIdOption); var clientSecret = context.ParseResult.GetValueForOption(clientSecretOption); var tenantId = context.ParseResult.GetValueForOption(tenantIdOption); - var environmentUrl = context.ParseResult.GetValueForOption(environmentUrlOption); + var environmentUrl = context.ParseResult.GetValueForOption(environmentUrlOption)!; var verbosity = context.ParseResult.GetValueForOption(verbosityOption)!; var output = context.ParseResult.GetValueForOption(outputPathOption)!; - var reportFormat = context.ParseResult.GetValueForOption(reportFormatOption)!; - var reportVerbosity = context.ParseResult.GetValueForOption(reportVerbosityOption)!; + var format = context.ParseResult.GetValueForOption(formatOption)!; + var failOnSeverity = context.ParseResult.GetValueForOption(failOnSeverityOption); + var maxFindings = context.ParseResult.GetValueForOption(maxFindingsOption); var logLevel = verbosity.ToLowerInvariant() switch { @@ -145,19 +110,19 @@ static SolutionAnalyzerCli CreateCli(InvocationContext context) "minimal" => LogLevel.Warning, "normal" => LogLevel.Information, "detailed" => LogLevel.Debug, - "diagnostic" => LogLevel.Trace, _ => LogLevel.Information }; - return new SolutionAnalyzerCli( + return new ReportExecutor( config, + environmentUrl, connectionString, clientId, clientSecret, tenantId, - environmentUrl, logLevel, output, - reportFormat, - reportVerbosity); + format, + failOnSeverity, + maxFindings); } diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/ReportExecutor.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/ReportExecutor.cs new file mode 100644 index 0000000..089750e --- /dev/null +++ b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/ReportExecutor.cs @@ -0,0 +1,452 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using Ddk.SolutionLayerAnalyzer; +using Ddk.SolutionLayerAnalyzer.Models; +using Ddk.SolutionLayerAnalyzer.DTOs; + +namespace DataverseDevKit.SolutionAnalyzer.CLI; + +/// +/// Executes reports from configuration for CI/CD monitoring by directly calling the plugin +/// +public class ReportExecutor +{ + private readonly FileInfo _configFile; + private readonly string _environmentUrl; + private readonly string? _connectionString; + private readonly string? _clientId; + private readonly string? _clientSecret; + private readonly string? _tenantId; + private readonly DirectoryInfo _outputDirectory; + private readonly string _format; + private readonly string? _failOnSeverity; + private readonly int? _maxFindings; + private readonly ILogger _consoleLogger; + private readonly string _pluginLogPath; + + public ReportExecutor( + FileInfo configFile, + string environmentUrl, + string? connectionString, + string? clientId, + string? clientSecret, + string? tenantId, + LogLevel consoleLogLevel, + DirectoryInfo outputDirectory, + string format, + string? failOnSeverity, + int? maxFindings) + { + _configFile = configFile; + _environmentUrl = environmentUrl; + _connectionString = connectionString; + _clientId = clientId; + _clientSecret = clientSecret; + _tenantId = tenantId; + _outputDirectory = outputDirectory; + _format = format; + _failOnSeverity = failOnSeverity; + _maxFindings = maxFindings; + + // Ensure output directory exists + if (!_outputDirectory.Exists) + { + _outputDirectory.Create(); + } + + // Setup console logging for CLI + var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(consoleLogLevel); + builder.AddConsole(); + }); + _consoleLogger = loggerFactory.CreateLogger(); + + // Setup file logging path for plugin (to sandbox plugin logs) + _pluginLogPath = Path.Combine(_outputDirectory.FullName, $"plugin-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log"); + } + + /// + /// Execute all reports from configuration and return exit code + /// + public async Task ExecuteReportsAsync(CancellationToken cancellationToken) + { + _consoleLogger.LogInformation("Loading configuration from: {ConfigFile}", _configFile.FullName); + + try + { + // Load configuration + var config = await LoadConfigurationAsync(cancellationToken); + + var totalReports = config.ReportGroups.Sum(g => g.Reports.Count) + config.UngroupedReports.Count; + _consoleLogger.LogInformation("Loaded {ReportCount} reports from {GroupCount} groups", + totalReports, config.ReportGroups.Count); + + // Create plugin instance and context + var plugin = new SolutionLayerAnalyzerPlugin(); + var pluginLogger = CreatePluginLogger(); + var serviceClientFactory = new CliServiceClientFactory( + _environmentUrl, + _connectionString, + _clientId, + _clientSecret, + _tenantId); + + var pluginContext = new CliPluginContext( + pluginLogger, + _outputDirectory.FullName, + serviceClientFactory); + + _consoleLogger.LogDebug("Initializing plugin"); + await plugin.InitializeAsync(pluginContext, cancellationToken); + + try + { + // First, ensure we have an index + _consoleLogger.LogInformation("Checking index status"); + + // Build index request from config + var indexRequest = new IndexRequest + { + ConnectionId = _environmentUrl, + SourceSolutions = config.SourceSolutions, + TargetSolutions = config.TargetSolutions, + ComponentTypes = config.ComponentTypes ?? new List(), + PayloadMode = "lazy" + }; + + var indexPayload = JsonSerializer.Serialize(indexRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + _consoleLogger.LogInformation("Building index for {SourceCount} sources and {TargetCount} targets", + config.SourceSolutions.Count, config.TargetSolutions.Count); + + await plugin.ExecuteAsync("index", indexPayload, cancellationToken); + + // Execute all reports and collect results + var criticalCount = 0; + var warningCount = 0; + var infoCount = 0; + var allComponents = new List(); + + foreach (var group in config.ReportGroups) + { + _consoleLogger.LogInformation("Executing group: {GroupName}", group.Name); + + foreach (var report in group.Reports) + { + _consoleLogger.LogDebug(" - {ReportName} (Severity: {Severity})", + report.Name, report.Severity); + + // Execute report via plugin + var reportResult = await ExecuteReportViaPlugin( + plugin, + report, + _environmentUrl, + cancellationToken); + + // Accumulate findings + switch (report.Severity) + { + case ReportSeverity.Critical: + criticalCount += reportResult.TotalMatches; + break; + case ReportSeverity.Warning: + warningCount += reportResult.TotalMatches; + break; + case ReportSeverity.Information: + infoCount += reportResult.TotalMatches; + break; + } + + allComponents.AddRange(reportResult.Components); + } + } + + // Generate output report + var reportFileName = $"report-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{GetFileExtension(_format)}"; + var reportPath = Path.Combine(_outputDirectory.FullName, reportFileName); + + await GenerateOutputReportAsync(config, allComponents, reportPath, cancellationToken); + + _consoleLogger.LogInformation("Report saved to: {ReportPath}", reportPath); + _consoleLogger.LogInformation("Plugin logs: {PluginLog}", _pluginLogPath); + + // Print summary + PrintSummary(totalReports, criticalCount, warningCount, infoCount, reportPath); + + // Determine exit code + var exitCode = DetermineExitCode(criticalCount, warningCount, infoCount); + + PrintExitStatus(exitCode, criticalCount, warningCount, infoCount); + + return exitCode; + } + finally + { + await plugin.DisposeAsync(); + } + } + catch (Exception ex) + { + _consoleLogger.LogError(ex, "Report execution failed"); + PrintErrorSummary(ex); + return 2; // Error exit code + } + } + + private async Task ExecuteReportViaPlugin( + SolutionLayerAnalyzerPlugin plugin, + ConfigReport report, + string connectionId, + CancellationToken cancellationToken) + { + // Create a temporary saved report to execute + var saveRequest = new SaveReportRequest + { + ConnectionId = connectionId, + Name = report.Name, + Description = report.Description, + Severity = report.Severity, + RecommendedAction = report.RecommendedAction, + QueryJson = report.QueryJson + }; + + var savePayload = JsonSerializer.Serialize(saveRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var savedReportElement = await plugin.ExecuteAsync("saveReport", savePayload, cancellationToken); + var savedReport = JsonSerializer.Deserialize( + savedReportElement.GetRawText(), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + if (savedReport == null) + { + throw new InvalidOperationException($"Failed to save report: {report.Name}"); + } + + // Execute the report + var executeRequest = new ExecuteReportRequest + { + Id = savedReport.Id, + ConnectionId = connectionId + }; + + var executePayload = JsonSerializer.Serialize(executeRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var resultElement = await plugin.ExecuteAsync("executeReport", executePayload, cancellationToken); + var result = JsonSerializer.Deserialize( + resultElement.GetRawText(), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + if (result == null) + { + throw new InvalidOperationException($"Failed to execute report: {report.Name}"); + } + + // Clean up the temporary report + var deleteRequest = new DeleteReportRequest + { + Id = savedReport.Id, + ConnectionId = connectionId + }; + + var deletePayload = JsonSerializer.Serialize(deleteRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + await plugin.ExecuteAsync("deleteReport", deletePayload, cancellationToken); + + return result; + } + + private async Task GenerateOutputReportAsync( + AnalyzerConfig config, + List components, + string outputPath, + CancellationToken cancellationToken) + { + // Create simple output structure + var output = new + { + GeneratedAt = DateTime.UtcNow, + SourceSolutions = config.SourceSolutions, + TargetSolutions = config.TargetSolutions, + TotalComponents = components.Count, + Components = components + }; + + string content; + if (_format.ToLowerInvariant() == "json") + { + content = JsonSerializer.Serialize(output, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + else if (_format.ToLowerInvariant() == "csv") + { + // Simple CSV output + var lines = new List + { + "ComponentId,ComponentType,ComponentTypeName,LogicalName,DisplayName,Solutions" + }; + + foreach (var comp in components) + { + lines.Add($"{comp.ComponentId},{comp.ComponentType},{comp.ComponentTypeName},{comp.LogicalName},{comp.DisplayName},{string.Join(";", comp.Solutions)}"); + } + + content = string.Join(Environment.NewLine, lines); + } + else // YAML + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + content = serializer.Serialize(output); + } + + await File.WriteAllTextAsync(outputPath, content, cancellationToken); + } + + private ILogger CreatePluginLogger() + { + // Create file-only logger for plugin to sandbox its logs + var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Debug); + builder.AddProvider(new FileLoggerProvider(_pluginLogPath, LogLevel.Debug)); + }); + + return loggerFactory.CreateLogger("Plugin"); + } + + private void PrintSummary(int totalReports, int criticalCount, int warningCount, int infoCount, string reportPath) + { + Console.WriteLine(); + Console.WriteLine("═══════════════════════════════════════════════════════════"); + Console.WriteLine(" SOLUTION INTEGRITY REPORT SUMMARY"); + Console.WriteLine("═══════════════════════════════════════════════════════════"); + Console.WriteLine($" Reports Executed: {totalReports}"); + Console.WriteLine($" Critical Findings: {criticalCount}"); + Console.WriteLine($" Warning Findings: {warningCount}"); + Console.WriteLine($" Info Findings: {infoCount}"); + Console.WriteLine($" Total Findings: {criticalCount + warningCount + infoCount}"); + Console.WriteLine("───────────────────────────────────────────────────────────"); + Console.WriteLine($" Output: {reportPath}"); + Console.WriteLine($" Plugin Logs: {_pluginLogPath}"); + Console.WriteLine("═══════════════════════════════════════════════════════════"); + } + + private void PrintExitStatus(int exitCode, int criticalCount, int warningCount, int infoCount) + { + if (exitCode == 0) + { + Console.WriteLine(" Status: ✓ PASSED - Solution integrity check successful"); + } + else + { + Console.WriteLine($" Status: ✗ FAILED - Exit code {exitCode}"); + if (!string.IsNullOrEmpty(_failOnSeverity)) + { + Console.WriteLine($" Reason: Findings with severity '{_failOnSeverity}' or higher detected"); + } + if (_maxFindings.HasValue) + { + Console.WriteLine($" Reason: Total findings ({criticalCount + warningCount + infoCount}) exceeds threshold ({_maxFindings})"); + } + } + Console.WriteLine("═══════════════════════════════════════════════════════════"); + Console.WriteLine(); + } + + private void PrintErrorSummary(Exception ex) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("═══════════════════════════════════════════════════════════"); + Console.Error.WriteLine(" ✗ EXECUTION FAILED"); + Console.Error.WriteLine("═══════════════════════════════════════════════════════════"); + Console.Error.WriteLine($" Error: {ex.Message}"); + Console.Error.WriteLine($" See plugin logs: {_pluginLogPath}"); + Console.Error.WriteLine("═══════════════════════════════════════════════════════════"); + Console.Error.WriteLine(); + } + + private int DetermineExitCode(int criticalCount, int warningCount, int infoCount) + { + var totalFindings = criticalCount + warningCount + infoCount; + + // Check max findings threshold first + if (_maxFindings.HasValue && totalFindings > _maxFindings.Value) + { + _consoleLogger.LogWarning("Total findings ({Total}) exceeds threshold ({Max})", + totalFindings, _maxFindings.Value); + return 1; + } + + // Check severity-based failure + if (!string.IsNullOrEmpty(_failOnSeverity)) + { + var failSeverity = _failOnSeverity.ToLowerInvariant(); + + if (failSeverity == "information" && totalFindings > 0) + { + _consoleLogger.LogWarning("Failing on information severity: {Total} findings", totalFindings); + return 1; + } + + if (failSeverity == "warning" && (warningCount > 0 || criticalCount > 0)) + { + _consoleLogger.LogWarning("Failing on warning severity: {Warning} warnings, {Critical} critical", + warningCount, criticalCount); + return 1; + } + + if (failSeverity == "critical" && criticalCount > 0) + { + _consoleLogger.LogError("Failing on critical severity: {Critical} critical findings", criticalCount); + return 1; + } + } + + // No failures - success + return 0; + } + + private async Task LoadConfigurationAsync(CancellationToken cancellationToken) + { + if (!_configFile.Exists) + { + throw new FileNotFoundException($"Configuration file not found: {_configFile.FullName}"); + } + + var yaml = await File.ReadAllTextAsync(_configFile.FullName, cancellationToken); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + return deserializer.Deserialize(yaml); + } + + private static string GetFileExtension(string format) + { + return format.ToLowerInvariant() switch + { + "json" => "json", + "csv" => "csv", + _ => "yaml" + }; + } +} diff --git a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs b/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs deleted file mode 100644 index 09f58c1..0000000 --- a/src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI/SolutionAnalyzerCli.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; -using Ddk.SolutionLayerAnalyzer; -using Ddk.SolutionLayerAnalyzer.Models; -using Ddk.SolutionLayerAnalyzer.DTOs; - -namespace DataverseDevKit.SolutionAnalyzer.CLI; - -/// -/// Main CLI implementation for the Solution Analyzer -/// -public class SolutionAnalyzerCli -{ - private readonly FileInfo _configFile; - private readonly string? _connectionString; - private readonly string? _clientId; - private readonly string? _clientSecret; - private readonly string? _tenantId; - private readonly string? _environmentUrl; - private readonly LogLevel _logLevel; - private readonly DirectoryInfo _outputDirectory; - private readonly string _reportFormat; - private readonly string _reportVerbosity; - private readonly ILogger _logger; - private readonly string _logFilePath; - - public SolutionAnalyzerCli( - FileInfo configFile, - string? connectionString, - string? clientId, - string? clientSecret, - string? tenantId, - string? environmentUrl, - LogLevel logLevel, - DirectoryInfo outputDirectory, - string reportFormat, - string reportVerbosity) - { - _configFile = configFile; - _connectionString = connectionString; - _clientId = clientId; - _clientSecret = clientSecret; - _tenantId = tenantId; - _environmentUrl = environmentUrl; - _logLevel = logLevel; - _outputDirectory = outputDirectory; - _reportFormat = reportFormat; - _reportVerbosity = reportVerbosity; - - // Ensure output directory exists - if (!_outputDirectory.Exists) - { - _outputDirectory.Create(); - } - - // Setup file logging - _logFilePath = Path.Combine(_outputDirectory.FullName, $"cli-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log"); - var loggerFactory = LoggerFactory.Create(builder => - { - builder.AddProvider(new FileLoggerProvider(_logFilePath, logLevel)); - builder.AddConsole(); - }); - _logger = loggerFactory.CreateLogger(); - - _logger.LogInformation("Solution Analyzer CLI initialized"); - _logger.LogInformation("Config file: {ConfigFile}", _configFile.FullName); - _logger.LogInformation("Output directory: {OutputDirectory}", _outputDirectory.FullName); - _logger.LogInformation("Log file: {LogFile}", _logFilePath); - } - - public async Task ExecuteIndexAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("=== Starting Index Operation ==="); - - try - { - // Load configuration - var config = await LoadConfigurationAsync(cancellationToken); - _logger.LogInformation("Configuration loaded: {SourceCount} sources, {TargetCount} targets", - config.SourceSolutions.Count, config.TargetSolutions.Count); - - // TODO: Initialize plugin and execute index command - // This requires setting up the plugin context, service client factory, etc. - // For now, log what would be done - _logger.LogInformation("Would index solutions: {Sources} -> {Targets}", - string.Join(", ", config.SourceSolutions), - string.Join(", ", config.TargetSolutions)); - - Console.WriteLine("✓ Index operation completed successfully"); - Console.WriteLine($" Log file: {_logFilePath}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Index operation failed"); - Console.Error.WriteLine($"✗ Index operation failed: {ex.Message}"); - Console.Error.WriteLine($" See log file for details: {_logFilePath}"); - throw; - } - } - - public async Task ExecuteReportsAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("=== Starting Report Execution ==="); - - try - { - // Load configuration - var config = await LoadConfigurationAsync(cancellationToken); - - var totalReports = config.ReportGroups.Sum(g => g.Reports.Count) + config.UngroupedReports.Count; - _logger.LogInformation("Loaded {GroupCount} groups with {ReportCount} total reports", - config.ReportGroups.Count, totalReports); - - // TODO: Execute all reports via plugin - // For now, log what would be done - foreach (var group in config.ReportGroups) - { - _logger.LogInformation("Group: {GroupName} ({Count} reports)", group.Name, group.Reports.Count); - foreach (var report in group.Reports) - { - _logger.LogInformation(" - {ReportName} (Severity: {Severity})", report.Name, report.Severity); - } - } - - // Generate summary report - var reportFileName = $"report-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{GetFileExtension(_reportFormat)}"; - var reportPath = Path.Combine(_outputDirectory.FullName, reportFileName); - - _logger.LogInformation("Report would be generated at: {ReportPath}", reportPath); - - Console.WriteLine("✓ Report execution completed successfully"); - Console.WriteLine($" Reports executed: {totalReports}"); - Console.WriteLine($" Output file: {reportPath}"); - Console.WriteLine($" Log file: {_logFilePath}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Report execution failed"); - Console.Error.WriteLine($"✗ Report execution failed: {ex.Message}"); - Console.Error.WriteLine($" See log file for details: {_logFilePath}"); - throw; - } - } - - public async Task ExportConfigAsync(FileInfo? outputFile, CancellationToken cancellationToken) - { - _logger.LogInformation("=== Starting Config Export ==="); - - try - { - // TODO: Export current configuration from plugin - // For now, create a sample config - var config = new AnalyzerConfig - { - SourceSolutions = new List { "CoreSolution" }, - TargetSolutions = new List { "Project1", "Project2" }, - ReportGroups = new List(), - UngroupedReports = new List() - }; - - var serializer = new SerializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - var yaml = serializer.Serialize(config); - - var exportPath = outputFile?.FullName ?? Path.Combine(_outputDirectory.FullName, "config-export.yaml"); - await File.WriteAllTextAsync(exportPath, yaml, cancellationToken); - - _logger.LogInformation("Configuration exported to: {ExportPath}", exportPath); - Console.WriteLine("✓ Configuration exported successfully"); - Console.WriteLine($" Output file: {exportPath}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Config export failed"); - Console.Error.WriteLine($"✗ Config export failed: {ex.Message}"); - throw; - } - } - - public async Task ImportConfigAsync(FileInfo inputFile, CancellationToken cancellationToken) - { - _logger.LogInformation("=== Starting Config Import ==="); - - try - { - if (!inputFile.Exists) - { - throw new FileNotFoundException($"Configuration file not found: {inputFile.FullName}"); - } - - var yaml = await File.ReadAllTextAsync(inputFile.FullName, cancellationToken); - var deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - var config = deserializer.Deserialize(yaml); - - var totalReports = config.ReportGroups.Sum(g => g.Reports.Count) + config.UngroupedReports.Count; - _logger.LogInformation("Imported configuration with {GroupCount} groups and {ReportCount} reports", - config.ReportGroups.Count, totalReports); - - // TODO: Import configuration to plugin - Console.WriteLine("✓ Configuration imported successfully"); - Console.WriteLine($" Groups: {config.ReportGroups.Count}"); - Console.WriteLine($" Reports: {totalReports}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Config import failed"); - Console.Error.WriteLine($"✗ Config import failed: {ex.Message}"); - throw; - } - } - - private async Task LoadConfigurationAsync(CancellationToken cancellationToken) - { - if (!_configFile.Exists) - { - throw new FileNotFoundException($"Configuration file not found: {_configFile.FullName}"); - } - - var yaml = await File.ReadAllTextAsync(_configFile.FullName, cancellationToken); - var deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - - return deserializer.Deserialize(yaml); - } - - private static string GetFileExtension(string format) - { - return format.ToLowerInvariant() switch - { - "json" => "json", - "csv" => "csv", - _ => "yaml" - }; - } -} diff --git a/src/cli/solution-analyzer/README.md b/src/cli/solution-analyzer/README.md index aef6f3d..e96016c 100644 --- a/src/cli/solution-analyzer/README.md +++ b/src/cli/solution-analyzer/README.md @@ -1,260 +1,130 @@ # Dataverse DevKit Solution Analyzer CLI -Enterprise-ready command-line tool for analyzing Dataverse solution component layering and generating detailed reports. +CI/CD monitoring tool for continuously checking Dataverse solution layer integrity. -## Features +## Purpose -- **YAML Configuration**: Define analysis settings and reports in human-readable YAML files -- **Multiple Output Formats**: Generate reports in YAML, JSON, or CSV for Excel processing -- **Report Verbosity Levels**: Control detail level (basic, medium, verbose) -- **Flexible Authentication**: Support for interactive login, connection strings, and service principals -- **Enterprise Reporting**: Define severity levels, actions, and group reports for organizational needs -- **File Logging**: Detailed logs for troubleshooting and audit trails +Execute configured reports against Dataverse environments and return exit codes for pipeline control. -## Installation - -Build the CLI tool: - -```bash -cd src/cli/solution-analyzer/DataverseDevKit.SolutionAnalyzer.CLI -dotnet build -c Release -``` - -The executable will be available at `bin/Release/net10.0/ddk-solution-analyzer` +**Design**: Simple executor - just runs reports, nothing more. Plugin logs go to file, CLI logs go to console. ## Quick Start -1. **Create a configuration file** (see `example-config.yaml`): - -```yaml -sourceSolutions: - - CoreSolution -targetSolutions: - - Project1 - - Project2 -reportGroups: - - name: Project 1 Analysis - reports: - - name: Empty Layers - severity: Information - # ... query definition -``` - -2. **Index your solutions**: - ```bash -ddk-solution-analyzer index \ +ddk-solution-analyzer \ --config analyzer-config.yaml \ --environment-url https://yourorg.crm.dynamics.com \ - --output ./reports + --fail-on-severity critical ``` -3. **Run all reports**: +## Exit Codes -```bash -ddk-solution-analyzer run \ - --config analyzer-config.yaml \ - --environment-url https://yourorg.crm.dynamics.com \ - --format yaml \ - --report-verbosity medium \ - --output ./reports -``` +- **0** - Success (no critical issues or within thresholds) +- **1** - Failure (severity/count thresholds exceeded) +- **2** - Error (execution failed) -## Commands +## Options -### `index` +| Option | Description | +|--------|-------------| +| `--config, -c` | YAML configuration file **(required)** | +| `--environment-url, -e` | Dataverse environment URL **(required)** | +| `--client-id` | Azure AD client ID (service principal) | +| `--client-secret` | Azure AD client secret | +| `--tenant-id` | Azure AD tenant ID | +| `--output, -o` | Output directory (default: current) | +| `--format, -f` | Report format: yaml, json, csv (default: yaml) | +| `--verbosity, -v` | Console log level: quiet, minimal, normal, detailed | +| `--fail-on-severity` | Fail on: critical, warning, information | +| `--max-findings` | Max total findings before failing | -Build an index of solutions, components, and their layers. +## CI/CD Examples -```bash -ddk-solution-analyzer index \ - --config \ - --environment-url \ - [--verbosity ] \ - [--output ] -``` - -### `run` - -Execute all reports defined in the configuration file. +### GitHub Actions -```bash -ddk-solution-analyzer run \ - --config \ - --environment-url \ - [--format yaml|json|csv] \ - [--report-verbosity basic|medium|verbose] \ - [--verbosity ] \ - [--output ] +```yaml +- name: Check Solution Integrity + run: | + ddk-solution-analyzer \ + --config analyzer-config.yaml \ + --environment-url ${{ secrets.DATAVERSE_URL }} \ + --client-id ${{ secrets.CLIENT_ID }} \ + --client-secret ${{ secrets.CLIENT_SECRET }} \ + --tenant-id ${{ secrets.TENANT_ID }} \ + --fail-on-severity critical \ + --verbosity minimal ``` -### `export` +### Azure DevOps -Export current report configuration to YAML. - -```bash -ddk-solution-analyzer export \ - --config \ - --environment-url \ - [--file ] \ - [--output ] +```yaml +- script: | + ddk-solution-analyzer \ + --config analyzer-config.yaml \ + --environment-url $(DataverseUrl) \ + --client-id $(ClientId) \ + --client-secret $(ClientSecret) \ + --tenant-id $(TenantId) \ + --fail-on-severity warning + displayName: 'Solution Integrity Check' ``` -### `import` - -Import report configuration from YAML. +### GitLab CI -```bash -ddk-solution-analyzer import \ - --config \ - --environment-url \ - --file \ - [--output ] +```yaml +integrity-check: + script: + - ddk-solution-analyzer --config config.yaml -e $DATAVERSE_URL + --client-id $CLIENT_ID --client-secret $CLIENT_SECRET + --fail-on-severity critical --verbosity minimal ``` -## Global Options - -### Authentication Options - -**Connection String** (easiest for testing): -```bash ---connection-string "AuthType=OAuth;Username=user@domain.com;..." -``` +## Output -**Service Principal** (recommended for automation): -```bash ---client-id \ ---client-secret \ ---tenant-id \ ---environment-url ``` - -**Interactive** (default): -```bash ---environment-url +═══════════════════════════════════════════════════════════ + SOLUTION INTEGRITY REPORT SUMMARY +═══════════════════════════════════════════════════════════ + Reports Executed: 4 + Critical Findings: 2 + Warning Findings: 5 + Info Findings: 3 + Total Findings: 10 +─────────────────────────────────────────────────────────── + Output: report-20260201-120000.yaml + Plugin Logs: plugin-20260201-120000.log +═══════════════════════════════════════════════════════════ + Status: ✗ FAILED - Exit code 1 + Reason: Findings with severity 'critical' or higher detected +═══════════════════════════════════════════════════════════ ``` -### Output Options - -- `--output, -o`: Output directory for reports and logs (default: current directory) -- `--format, -f`: Report format - yaml, json, or csv (default: yaml) -- `--report-verbosity, -rv`: Report detail level - basic, medium, or verbose (default: basic) - -### Logging Options - -- `--verbosity, -v`: Log verbosity - quiet, minimal, normal, detailed, or diagnostic (default: normal) - -## Report Verbosity Levels +## Logging -### Basic -- Component ID, type, logical name, display name -- List of solutions containing the component -- Minimal detail for quick overview +- **CLI logs** → Console (based on `--verbosity`) +- **Plugin logs** → File (sandboxed, always written to `plugin-{timestamp}.log`) -### Medium -- All basic information -- List of changed attributes per layer -- Good balance for most scenarios +## Configuration -### Verbose -- All medium information -- Full attribute change details (old/new values) -- Maximum detail for deep analysis - -## Output Formats - -### YAML -Human-readable, structured format ideal for reviewing reports manually. - -### JSON -Machine-readable format perfect for integration with other tools and CI/CD pipelines. - -### CSV -Flat format optimized for Excel processing and pivot tables. - -## Configuration File Structure +See `example-config.yaml`: ```yaml -# Global settings -sourceSolutions: - - CoreSolution -targetSolutions: - - Project1 - - Project2 - -# Optional: Limit to specific component types -componentTypes: - - 1 # Entity - - 24 # SystemForm - - 26 # SavedQuery - -# Organized reports +sourceSolutions: [CoreSolution] +targetSolutions: [Project1, Project2] reportGroups: - - name: Group Name - displayOrder: 1 + - name: Critical Checks reports: - - name: Report Name - description: What this report checks - severity: Information|Warning|Critical - recommendedAction: What to do with findings - displayOrder: 1 - queryJson: '{ ... filter query ... }' - -# Reports not in any group -ungroupedReports: - - name: Standalone Report - # ... same fields as above + - name: Conflicting Layers + severity: Critical + recommendedAction: Resolve immediately + queryJson: '...' ``` -## Examples - -### CI/CD Integration - -```bash -#!/bin/bash -# Run solution analysis in CI/CD pipeline - -ddk-solution-analyzer run \ - --config solution-analysis.yaml \ - --client-id $AZURE_CLIENT_ID \ - --client-secret $AZURE_CLIENT_SECRET \ - --tenant-id $AZURE_TENANT_ID \ - --environment-url $DATAVERSE_URL \ - --format csv \ - --report-verbosity medium \ - --verbosity minimal \ - --output ./build/reports - -# Check exit code -if [ $? -ne 0 ]; then - echo "Analysis failed!" - exit 1 -fi - -echo "Analysis completed successfully" -``` - -### Generate Multiple Format Reports - -```bash -# YAML for manual review -ddk-solution-analyzer run --config config.yaml -f yaml -o ./reports/yaml - -# JSON for automation -ddk-solution-analyzer run --config config.yaml -f json -o ./reports/json - -# CSV for Excel -ddk-solution-analyzer run --config config.yaml -f csv -o ./reports/csv -``` - -## Troubleshooting - -- **Log files**: Check the CLI log file in the output directory for detailed execution logs -- **Verbosity**: Increase verbosity with `--verbosity diagnostic` for maximum detail -- **Authentication**: Verify credentials and permissions if connection fails +## Best Practices -## See Also +| Environment | Recommended Settings | +|-------------|---------------------| +| Development | `--fail-on-severity warning` | +| QA/UAT | `--fail-on-severity critical` | +| Production Gate | `--fail-on-severity critical --max-findings 0` | -- [Example Configuration](example-config.yaml) -- [Plugin Documentation](../../plugins/solution-layer-analyzer/README.md) -- [Query Syntax Reference](../../plugins/solution-layer-analyzer/docs/query-syntax.md) From 64d505a37fdcd0c6ace92e39e7524f17df9d338b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:53:29 +0000 Subject: [PATCH 06/14] Add Report Builder UI tab with visual config builder and Save To Report functionality Co-authored-by: mathis-m <11584315+mathis-m@users.noreply.github.com> --- .../solution-layer-analyzer/ui/src/Plugin.tsx | 7 + .../ui/src/components/AnalysisTab.tsx | 7 + .../ui/src/components/ComponentFilterBar.tsx | 26 +- .../ui/src/components/ReportBuilderTab.tsx | 757 ++++++++++++++++++ .../ui/src/components/SaveToReportDialog.tsx | 232 ++++++ 5 files changed, 1023 insertions(+), 6 deletions(-) create mode 100644 src/plugins/solution-layer-analyzer/ui/src/components/ReportBuilderTab.tsx create mode 100644 src/plugins/solution-layer-analyzer/ui/src/components/SaveToReportDialog.tsx diff --git a/src/plugins/solution-layer-analyzer/ui/src/Plugin.tsx b/src/plugins/solution-layer-analyzer/ui/src/Plugin.tsx index b52e0bb..6ea25de 100644 --- a/src/plugins/solution-layer-analyzer/ui/src/Plugin.tsx +++ b/src/plugins/solution-layer-analyzer/ui/src/Plugin.tsx @@ -14,11 +14,13 @@ import { ChartMultipleRegular, CodeRegular, ChartPerson24Regular, + ClipboardTaskListLtrRegular, } from '@fluentui/react-icons'; import { ImprovedIndexTab } from './components/ImprovedIndexTab'; import { AnalysisTab } from './components/AnalysisTab'; import { DiffTab } from './components/DiffTab'; import { AnalysisDashboard } from './components/AnalysisDashboard'; +import { ReportBuilderTab } from './components/ReportBuilderTab'; import { Footer } from './components/Footer'; import { ProgressIndicator } from './components/ProgressIndicator'; import { SaveConfigDialog } from './components/SaveConfigDialog'; @@ -232,6 +234,7 @@ const Plugin: React.FC = ({ instanceId }) => { } value="index">Index } value="analysis">Analysis } value="advanced">Advanced Analytics + } value="reports">Report Builder } value="diff">Diff @@ -247,6 +250,10 @@ const Plugin: React.FC = ({ instanceId }) => { )} + {selectedTab === 'reports' && ( + + )} + {selectedTab === 'diff' && ( = ({ onNavigateToDiff }) => )} + + ); }; diff --git a/src/plugins/solution-layer-analyzer/ui/src/components/ComponentFilterBar.tsx b/src/plugins/solution-layer-analyzer/ui/src/components/ComponentFilterBar.tsx index 07ec651..30ecc43 100644 --- a/src/plugins/solution-layer-analyzer/ui/src/components/ComponentFilterBar.tsx +++ b/src/plugins/solution-layer-analyzer/ui/src/components/ComponentFilterBar.tsx @@ -16,6 +16,7 @@ import { DismissRegular, FilterRegular, InfoRegular, + SaveRegular, } from '@fluentui/react-icons'; import { ComponentResult, FilterNode } from '../types'; import { AdvancedFilterBuilder } from './AdvancedFilterBuilder'; @@ -53,6 +54,7 @@ interface ComponentFilterBarProps { availableSolutions?: string[]; onFilterChange: (filteredComponents: ComponentResult[]) => void; onAdvancedFilterChange?: (filter: FilterNode | null) => void; + onSaveToReport?: () => void; loading?: boolean; } @@ -61,6 +63,7 @@ export const ComponentFilterBar: React.FC = ({ availableSolutions, onFilterChange, onAdvancedFilterChange, + onSaveToReport, loading, }) => { const styles = useStyles(); @@ -237,12 +240,23 @@ export const ComponentFilterBar: React.FC = ({ {activeFilterCount > 0 && ( - + <> + + {onSaveToReport && ( + + )} + )} diff --git a/src/plugins/solution-layer-analyzer/ui/src/components/ReportBuilderTab.tsx b/src/plugins/solution-layer-analyzer/ui/src/components/ReportBuilderTab.tsx new file mode 100644 index 0000000..27f51fa --- /dev/null +++ b/src/plugins/solution-layer-analyzer/ui/src/components/ReportBuilderTab.tsx @@ -0,0 +1,757 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + makeStyles, + tokens, + Card, + CardHeader, + Text, + Button, + Input, + Dropdown, + Option, + Textarea, + Badge, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + Divider, + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogBody, + DialogActions, + DialogContent, + Accordion, + AccordionItem, + AccordionHeader, + AccordionPanel, + Label, + Spinner, +} from '@fluentui/react-components'; +import { + AddRegular, + MoreVerticalRegular, + ArrowUpRegular, + ArrowDownRegular, + DeleteRegular, + CopyRegular, + FolderRegular, + DocumentRegular, + PlayRegular, + SaveRegular, + FolderOpenRegular, + ArrowExportRegular, + FilterRegular, + ArrowSyncRegular, +} from '@fluentui/react-icons'; +import { usePluginApi } from '../hooks/usePluginApi'; +import { useAppStore } from '../store/useAppStore'; +import { AdvancedFilterBuilder } from './AdvancedFilterBuilder'; +import { FilterNode } from '../types'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + }, + toolbar: { + display: 'flex', + gap: tokens.spacingHorizontalM, + alignItems: 'center', + paddingBlock: tokens.spacingVerticalM, + }, + content: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + groupCard: { + marginBottom: tokens.spacingVerticalM, + }, + groupHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: tokens.spacingVerticalM, + backgroundColor: tokens.colorNeutralBackground3, + }, + reportItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: tokens.spacingVerticalM, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + '&:hover': { + backgroundColor: tokens.colorNeutralBackground2, + }, + }, + reportInfo: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + flex: 1, + }, + reportActions: { + display: 'flex', + gap: tokens.spacingHorizontalS, + alignItems: 'center', + }, + formField: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + marginBottom: tokens.spacingVerticalM, + }, + filterSection: { + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + padding: tokens.spacingVerticalM, + marginBottom: tokens.spacingVerticalM, + }, + resultsContainer: { + marginTop: tokens.spacingVerticalL, + padding: tokens.spacingVerticalM, + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, + }, +}); + +interface Report { + name: string; + description?: string; + severity: 'Information' | 'Warning' | 'Critical'; + recommendedAction?: string; + queryJson: string; + displayOrder: number; +} + +interface ReportGroup { + name: string; + displayOrder: number; + reports: Report[]; +} + +interface ReportConfig { + sourceSolutions: string[]; + targetSolutions: string[]; + componentTypes?: number[]; + reportGroups: ReportGroup[]; + ungroupedReports: Report[]; +} + +export const ReportBuilderTab: React.FC = () => { + const styles = useStyles(); + const { executeCommand } = usePluginApi(); + const availableSolutions = useAppStore((state) => state.availableSolutions); + const indexConfig = useAppStore((state) => state.indexConfig); + + const [config, setConfig] = useState({ + sourceSolutions: indexConfig.sourceSolutions || [], + targetSolutions: indexConfig.targetSolutions || [], + componentTypes: [], + reportGroups: [], + ungroupedReports: [], + }); + + const [editingReport, setEditingReport] = useState<{ + groupIndex: number | null; + reportIndex: number; + report: Report; + } | null>(null); + + const [showFilterBuilder, setShowFilterBuilder] = useState(false); + const [currentFilter, setCurrentFilter] = useState(null); + const [reportResults, setReportResults] = useState(null); + const [isRunning, setIsRunning] = useState(false); + + // Add new group + const handleAddGroup = useCallback(() => { + const newGroup: ReportGroup = { + name: `New Group ${config.reportGroups.length + 1}`, + displayOrder: config.reportGroups.length, + reports: [], + }; + setConfig((prev) => ({ + ...prev, + reportGroups: [...prev.reportGroups, newGroup], + })); + }, [config.reportGroups.length]); + + // Add new report + const handleAddReport = useCallback((groupIndex: number | null) => { + const newReport: Report = { + name: 'New Report', + severity: 'Information', + queryJson: JSON.stringify(null), + displayOrder: groupIndex !== null + ? config.reportGroups[groupIndex].reports.length + : config.ungroupedReports.length, + }; + + if (groupIndex !== null) { + setConfig((prev) => { + const newGroups = [...prev.reportGroups]; + newGroups[groupIndex].reports.push(newReport); + return { ...prev, reportGroups: newGroups }; + }); + } else { + setConfig((prev) => ({ + ...prev, + ungroupedReports: [...prev.ungroupedReports, newReport], + })); + } + + setEditingReport({ + groupIndex, + reportIndex: groupIndex !== null + ? config.reportGroups[groupIndex].reports.length + : config.ungroupedReports.length, + report: newReport, + }); + }, [config]); + + // Move group up/down + const handleMoveGroup = useCallback((index: number, direction: 'up' | 'down') => { + const newGroups = [...config.reportGroups]; + const targetIndex = direction === 'up' ? index - 1 : index + 1; + + if (targetIndex >= 0 && targetIndex < newGroups.length) { + [newGroups[index], newGroups[targetIndex]] = [newGroups[targetIndex], newGroups[index]]; + newGroups.forEach((group, i) => { + group.displayOrder = i; + }); + setConfig((prev) => ({ ...prev, reportGroups: newGroups })); + } + }, [config.reportGroups]); + + // Move report up/down + const handleMoveReport = useCallback((groupIndex: number | null, reportIndex: number, direction: 'up' | 'down') => { + const targetIndex = direction === 'up' ? reportIndex - 1 : reportIndex + 1; + + if (groupIndex !== null) { + const newGroups = [...config.reportGroups]; + const reports = [...newGroups[groupIndex].reports]; + + if (targetIndex >= 0 && targetIndex < reports.length) { + [reports[reportIndex], reports[targetIndex]] = [reports[targetIndex], reports[reportIndex]]; + reports.forEach((report, i) => { + report.displayOrder = i; + }); + newGroups[groupIndex].reports = reports; + setConfig((prev) => ({ ...prev, reportGroups: newGroups })); + } + } else { + const reports = [...config.ungroupedReports]; + if (targetIndex >= 0 && targetIndex < reports.length) { + [reports[reportIndex], reports[targetIndex]] = [reports[targetIndex], reports[reportIndex]]; + reports.forEach((report, i) => { + report.displayOrder = i; + }); + setConfig((prev) => ({ ...prev, ungroupedReports: reports })); + } + } + }, [config]); + + // Delete group + const handleDeleteGroup = useCallback((index: number) => { + setConfig((prev) => ({ + ...prev, + reportGroups: prev.reportGroups.filter((_, i) => i !== index), + })); + }, []); + + // Delete report + const handleDeleteReport = useCallback((groupIndex: number | null, reportIndex: number) => { + if (groupIndex !== null) { + setConfig((prev) => { + const newGroups = [...prev.reportGroups]; + newGroups[groupIndex].reports = newGroups[groupIndex].reports.filter((_, i) => i !== reportIndex); + return { ...prev, reportGroups: newGroups }; + }); + } else { + setConfig((prev) => ({ + ...prev, + ungroupedReports: prev.ungroupedReports.filter((_, i) => i !== reportIndex), + })); + } + }, []); + + // Duplicate report + const handleDuplicateReport = useCallback((groupIndex: number | null, reportIndex: number) => { + const report = groupIndex !== null + ? config.reportGroups[groupIndex].reports[reportIndex] + : config.ungroupedReports[reportIndex]; + + const duplicated = { + ...report, + name: `${report.name} (Copy)`, + }; + + if (groupIndex !== null) { + setConfig((prev) => { + const newGroups = [...prev.reportGroups]; + newGroups[groupIndex].reports.splice(reportIndex + 1, 0, duplicated); + return { ...prev, reportGroups: newGroups }; + }); + } else { + setConfig((prev) => { + const newReports = [...prev.ungroupedReports]; + newReports.splice(reportIndex + 1, 0, duplicated); + return { ...prev, ungroupedReports: newReports }; + }); + } + }, [config]); + + // Load config from file + const handleLoadConfig = useCallback(async () => { + try { + const result = await executeCommand('importConfig', { + connectionId: 'default', + }); + if (result) { + // Parse and set the loaded config + console.log('Loaded config:', result); + } + } catch (error) { + console.error('Failed to load config:', error); + } + }, [executeCommand]); + + // Save config to file + const handleSaveConfig = useCallback(async () => { + try { + await executeCommand('exportConfig', { + connectionId: 'default', + config: config, + }); + } catch (error) { + console.error('Failed to save config:', error); + } + }, [executeCommand, config]); + + // Run all reports + const handleRunReports = useCallback(async () => { + setIsRunning(true); + try { + // Execute reports via CLI-like behavior + const results = await executeCommand('generateReportOutput', { + connectionId: 'default', + format: 'json', + verbosity: 'basic', + }); + setReportResults(results); + } catch (error) { + console.error('Failed to run reports:', error); + } finally { + setIsRunning(false); + } + }, [executeCommand]); + + // Export results + const handleExportResults = useCallback(async (format: 'yaml' | 'json' | 'csv') => { + try { + await executeCommand('generateReportOutput', { + connectionId: 'default', + format, + verbosity: 'medium', + }); + } catch (error) { + console.error('Failed to export results:', error); + } + }, [executeCommand]); + + return ( +
+ {/* Toolbar */} +
+ + + + + + + + {reportResults && ( + <> + + + + + )} +
+ + {/* Content */} +
+ {/* Report Groups */} + {config.reportGroups.map((group, groupIndex) => ( + +
+
+ + {group.name} + {group.reports.length} reports +
+
+
+
+ + {/* Reports in group */} + {group.reports.map((report, reportIndex) => ( +
+
+
+ + {report.name} + + {report.severity} + +
+ {report.description && ( + + {report.description} + + )} +
+
+ +
+
+ ))} +
+ ))} + + {/* Ungrouped Reports */} + {config.ungroupedReports.length > 0 && ( + +
+
+ Ungrouped Reports + {config.ungroupedReports.length} reports +
+
+ + {config.ungroupedReports.map((report, reportIndex) => ( +
+
+
+ + {report.name} + + {report.severity} + +
+ {report.description && ( + + {report.description} + + )} +
+
+ +
+
+ ))} +
+ )} +
+ + {/* Results Display */} + {reportResults && ( +
+ Report Results +
+            {JSON.stringify(reportResults, null, 2)}
+          
+
+ )} + + {/* Edit Report Dialog */} + {editingReport && ( + !data.open && setEditingReport(null)} + > + + + Edit Report + +
+ + { + setEditingReport({ + ...editingReport, + report: { ...editingReport.report, name: data.value }, + }); + }} + /> +
+ +
+ +