diff --git a/src/AtcWeb.Domain/AtcApi/AtcApiGitHubRepositoryClient.cs b/src/AtcWeb.Domain/AtcApi/AtcApiGitHubRepositoryClient.cs index 97be64e..ae83c2d 100644 --- a/src/AtcWeb.Domain/AtcApi/AtcApiGitHubRepositoryClient.cs +++ b/src/AtcWeb.Domain/AtcApi/AtcApiGitHubRepositoryClient.cs @@ -10,6 +10,7 @@ public class AtcApiGitHubRepositoryClient private static readonly SemaphoreSlim SemaphorePaths = new(1, 1); private static readonly SemaphoreSlim SemaphoreFiles = new(1, 1); private static readonly SemaphoreSlim SemaphoreIssues = new(1, 1); + private static readonly SemaphoreSlim SemaphoreCompliance = new(1, 1); public AtcApiGitHubRepositoryClient( HttpClient httpClient, @@ -99,6 +100,55 @@ public AtcApiGitHubRepositoryClient( : (IsSuccessful: true, gitHubRepository: repository); } + public async Task<(bool IsSuccessful, List Summaries)> GetComplianceSummary( + CancellationToken cancellationToken = default) + { + const string cacheKey = CacheConstants.CacheKeyComplianceSummary; + if (memoryCache.TryGetValue(cacheKey, out List data)) + { + return (IsSuccessful: true, data!); + } + + var browserCached = await browserCache.GetAsync>(cacheKey); + if (browserCached is not null) + { + memoryCache.Set(cacheKey, browserCached, CacheConstants.AbsoluteExpirationRelativeToNow); + return (IsSuccessful: true, browserCached); + } + + await SemaphoreCompliance.WaitAsync(cancellationToken); + + try + { + const string url = $"{BaseAddress}/compliance-summary"; + + var responseMessage = await httpClient.GetAsync(url, cancellationToken); + if (!responseMessage.IsSuccessStatusCode) + { + return (IsSuccessful: false, []); + } + + var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken); + var result = JsonSerializer.Deserialize>(content, JsonSerializerOptionsFactory.Create()); + if (result is null) + { + return (IsSuccessful: false, []); + } + + memoryCache.Set(cacheKey, result, CacheConstants.AbsoluteExpirationRelativeToNow); + await browserCache.SetAsync(cacheKey, result); + return (IsSuccessful: true, result); + } + catch + { + return (IsSuccessful: false, []); + } + finally + { + SemaphoreCompliance.Release(); + } + } + public async Task<(bool IsSuccessful, List DotnetNugetPackagesMetadata)> GetLatestNugetPackageVersionsUsed( CancellationToken cancellationToken = default) { diff --git a/src/AtcWeb.Domain/AtcApi/Models/Compliance/AnalyzerPackageRef.cs b/src/AtcWeb.Domain/AtcApi/Models/Compliance/AnalyzerPackageRef.cs new file mode 100644 index 0000000..ad870d7 --- /dev/null +++ b/src/AtcWeb.Domain/AtcApi/Models/Compliance/AnalyzerPackageRef.cs @@ -0,0 +1,10 @@ +namespace AtcWeb.Domain.AtcApi.Models.Compliance; + +public sealed class AnalyzerPackageRef +{ + public string PackageId { get; init; } = string.Empty; + + public string Version { get; init; } = string.Empty; + + public bool IsLatest { get; init; } +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/AtcApi/Models/Compliance/EditorConfigStatus.cs b/src/AtcWeb.Domain/AtcApi/Models/Compliance/EditorConfigStatus.cs new file mode 100644 index 0000000..7b23c24 --- /dev/null +++ b/src/AtcWeb.Domain/AtcApi/Models/Compliance/EditorConfigStatus.cs @@ -0,0 +1,22 @@ +namespace AtcWeb.Domain.AtcApi.Models.Compliance; + +public sealed class EditorConfigStatus +{ + public bool RootPresent { get; set; } + + public bool RootIsLatest { get; set; } + + public string? RootVersion { get; set; } + + public bool SrcPresent { get; set; } + + public bool SrcIsLatest { get; set; } + + public string? SrcVersion { get; set; } + + public bool TestPresent { get; set; } + + public bool TestIsLatest { get; set; } + + public string? TestVersion { get; set; } +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceDetail.cs b/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceDetail.cs new file mode 100644 index 0000000..a0acbaa --- /dev/null +++ b/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceDetail.cs @@ -0,0 +1,18 @@ +namespace AtcWeb.Domain.AtcApi.Models.Compliance; + +public sealed class RepositoryComplianceDetail +{ + public List SrcFrameworks { get; init; } = []; + + public List TestFrameworks { get; init; } = []; + + public List SampleFrameworks { get; init; } = []; + + public List AnalyzerPackages { get; init; } = []; + + public List SuppressedRulesRoot { get; init; } = []; + + public List SuppressedRulesSrc { get; init; } = []; + + public List SuppressedRulesTest { get; init; } = []; +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceSignals.cs b/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceSignals.cs new file mode 100644 index 0000000..1e77f84 --- /dev/null +++ b/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceSignals.cs @@ -0,0 +1,32 @@ +namespace AtcWeb.Domain.AtcApi.Models.Compliance; + +public sealed class RepositoryComplianceSignals +{ + public bool HasGoodReadme { get; set; } + + public bool LicenseIsMit { get; set; } + + public bool HomepageIsAtcWeb { get; set; } + + public EditorConfigStatus EditorConfigStatus { get; init; } = new(); + + public bool UpdaterPresent { get; set; } + + public bool UpdaterTargetIsLatest { get; set; } + + public string? UpdaterProjectTarget { get; set; } + + public bool GlobalLangVersionIsLatest { get; set; } + + public string? GlobalLangVersion { get; set; } + + public bool GlobalTargetFrameworkIsLatest { get; set; } + + public string? GlobalTargetFramework { get; set; } + + public XunitV3Status XunitV3Status { get; set; } = XunitV3Status.NotApplicable; + + public WorkflowsStatus WorkflowsStatus { get; init; } = new(); + + public bool ReleasePleasePresent { get; set; } +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceSummary.cs b/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceSummary.cs new file mode 100644 index 0000000..7d535a0 --- /dev/null +++ b/src/AtcWeb.Domain/AtcApi/Models/Compliance/RepositoryComplianceSummary.cs @@ -0,0 +1,38 @@ +namespace AtcWeb.Domain.AtcApi.Models.Compliance; + +public sealed class RepositoryComplianceSummary +{ + public string Name { get; init; } = string.Empty; + + public string? Language { get; init; } + + public string? Description { get; init; } + + public string? Homepage { get; init; } + + public string? LicenseKey { get; init; } + + public string? DefaultBranch { get; init; } + + public List Topics { get; init; } = []; + + public int StargazersCount { get; init; } + + public int ForksCount { get; init; } + + public int OpenIssuesCount { get; init; } + + public DateTimeOffset? PushedAt { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? OldestOpenIssueAt { get; init; } + + public DateTimeOffset? NewestOpenIssueAt { get; init; } + + public RepositoryComplianceSignals Signals { get; init; } = new(); + + public RepositoryComplianceDetail Detail { get; init; } = new(); +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/AtcApi/Models/Compliance/WorkflowsStatus.cs b/src/AtcWeb.Domain/AtcApi/Models/Compliance/WorkflowsStatus.cs new file mode 100644 index 0000000..0e2d8ae --- /dev/null +++ b/src/AtcWeb.Domain/AtcApi/Models/Compliance/WorkflowsStatus.cs @@ -0,0 +1,16 @@ +namespace AtcWeb.Domain.AtcApi.Models.Compliance; + +public sealed class WorkflowsStatus +{ + public List Actions { get; init; } = []; + + public List DotnetVersions { get; init; } = []; + + public bool CheckoutIsLatest { get; set; } + + public bool SetupDotnetIsLatest { get; set; } + + public bool HasJavaSetup { get; set; } + + public bool DotnetVersionIsLatest { get; set; } +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/AtcApi/Models/Compliance/XunitV3Status.cs b/src/AtcWeb.Domain/AtcApi/Models/Compliance/XunitV3Status.cs new file mode 100644 index 0000000..36611ff --- /dev/null +++ b/src/AtcWeb.Domain/AtcApi/Models/Compliance/XunitV3Status.cs @@ -0,0 +1,8 @@ +namespace AtcWeb.Domain.AtcApi.Models.Compliance; + +public enum XunitV3Status +{ + Yes, + No, + NotApplicable, +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/Compliance/ComplianceFilterEngine.cs b/src/AtcWeb.Domain/Compliance/ComplianceFilterEngine.cs new file mode 100644 index 0000000..caca2e4 --- /dev/null +++ b/src/AtcWeb.Domain/Compliance/ComplianceFilterEngine.cs @@ -0,0 +1,62 @@ +namespace AtcWeb.Domain.Compliance; + +public static class ComplianceFilterEngine +{ + public static IReadOnlyList Apply( + IEnumerable source, + ComplianceFilterState state) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(state); + + var query = source.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(state.SearchText)) + { + var needle = state.SearchText.Trim(); + query = query.Where(s => + s.Name.Contains(needle, StringComparison.OrdinalIgnoreCase) || + (s.Description ?? string.Empty).Contains(needle, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(state.Language)) + { + query = query.Where(s => string.Equals(s.Language, state.Language, StringComparison.Ordinal)); + } + + if (state.Health is { } targetHealth) + { + query = query.Where(s => ComplianceHealth.Compute(s) == targetHealth); + } + + if (state.FailingSignals.Count > 0) + { + query = query.Where(s => state.FailingSignals.All(key => SignalIsFailing(s, key))); + } + + return query.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(); + } + + private static bool SignalIsFailing( + RepositoryComplianceSummary s, + string key) + => key switch + { + "ReadmeMissing" => !s.Signals.HasGoodReadme, + "LicenseWrong" => !s.Signals.LicenseIsMit, + "HomepageWrong" => !s.Signals.HomepageIsAtcWeb, + "EditorConfigBehind" => !s.Signals.EditorConfigStatus.RootIsLatest + || !s.Signals.EditorConfigStatus.SrcIsLatest + || !s.Signals.EditorConfigStatus.TestIsLatest, + "UpdaterBehind" => !s.Signals.UpdaterPresent || !s.Signals.UpdaterTargetIsLatest, + "LangVersionBehind" => !s.Signals.GlobalLangVersionIsLatest, + "TfmBehind" => !s.Signals.GlobalTargetFrameworkIsLatest, + "XunitNotV3" => s.Signals.XunitV3Status == XunitV3Status.No, + "WorkflowsBehind" => !s.Signals.WorkflowsStatus.CheckoutIsLatest + || !s.Signals.WorkflowsStatus.SetupDotnetIsLatest + || !s.Signals.WorkflowsStatus.DotnetVersionIsLatest + || s.Signals.WorkflowsStatus.HasJavaSetup, + "NoReleasePlease" => !s.Signals.ReleasePleasePresent, + _ => false, + }; +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/Compliance/ComplianceFilterState.cs b/src/AtcWeb.Domain/Compliance/ComplianceFilterState.cs new file mode 100644 index 0000000..02a2d12 --- /dev/null +++ b/src/AtcWeb.Domain/Compliance/ComplianceFilterState.cs @@ -0,0 +1,14 @@ +namespace AtcWeb.Domain.Compliance; + +public sealed class ComplianceFilterState +{ + public string? SearchText { get; set; } + + public string? Language { get; set; } + + public string? Category { get; set; } + + public HealthStatus? Health { get; set; } + + public List FailingSignals { get; set; } = []; +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/Compliance/ComplianceHealth.cs b/src/AtcWeb.Domain/Compliance/ComplianceHealth.cs new file mode 100644 index 0000000..a098a4d --- /dev/null +++ b/src/AtcWeb.Domain/Compliance/ComplianceHealth.cs @@ -0,0 +1,83 @@ +namespace AtcWeb.Domain.Compliance; + +public enum HealthStatus +{ + Ok, + Warning, + Error, +} + +public static class ComplianceHealth +{ + public static HealthStatus Compute(RepositoryComplianceSummary summary) + { + ArgumentNullException.ThrowIfNull(summary); + + var s = summary.Signals; + var isDotnet = string.Equals(summary.Language, "C#", StringComparison.Ordinal); + + if (!s.LicenseIsMit) + { + return HealthStatus.Error; + } + + if (s.WorkflowsStatus.HasJavaSetup) + { + return HealthStatus.Error; + } + + if (isDotnet && !s.GlobalTargetFrameworkIsLatest && !string.IsNullOrEmpty(s.GlobalTargetFramework)) + { + return HealthStatus.Error; + } + + if (!s.HasGoodReadme) + { + return HealthStatus.Warning; + } + + if (!s.HomepageIsAtcWeb) + { + return HealthStatus.Warning; + } + + if (isDotnet) + { + if (!s.UpdaterPresent || !s.UpdaterTargetIsLatest) + { + return HealthStatus.Warning; + } + + if (!s.EditorConfigStatus.RootIsLatest || + !s.EditorConfigStatus.SrcIsLatest || + !s.EditorConfigStatus.TestIsLatest) + { + return HealthStatus.Warning; + } + + if (!s.GlobalLangVersionIsLatest) + { + return HealthStatus.Warning; + } + + if (s.XunitV3Status == XunitV3Status.No) + { + return HealthStatus.Warning; + } + + if (!s.WorkflowsStatus.CheckoutIsLatest || + !s.WorkflowsStatus.SetupDotnetIsLatest || + !s.WorkflowsStatus.DotnetVersionIsLatest) + { + return HealthStatus.Warning; + } + + if (!s.ReleasePleasePresent) + { + return HealthStatus.Warning; + } + } + + return HealthStatus.Ok; + } +} \ No newline at end of file diff --git a/src/AtcWeb.Domain/GitHub/CacheConstants.cs b/src/AtcWeb.Domain/GitHub/CacheConstants.cs index 5c5b4ab..9a1238a 100644 --- a/src/AtcWeb.Domain/GitHub/CacheConstants.cs +++ b/src/AtcWeb.Domain/GitHub/CacheConstants.cs @@ -24,6 +24,8 @@ public static class CacheConstants public const string CacheKeyNugetPackageVersions = "NugetPackageVersions"; + public const string CacheKeyComplianceSummary = "ComplianceSummary"; + public static readonly TimeSpan SlidingExpiration = TimeSpan.FromHours(6); public static readonly TimeSpan AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12); diff --git a/src/AtcWeb.Domain/GitHub/GitHubRepositoryMetadataHelper.cs b/src/AtcWeb.Domain/GitHub/GitHubRepositoryMetadataHelper.cs index 6430ad3..9ee505d 100644 --- a/src/AtcWeb.Domain/GitHub/GitHubRepositoryMetadataHelper.cs +++ b/src/AtcWeb.Domain/GitHub/GitHubRepositoryMetadataHelper.cs @@ -26,33 +26,6 @@ public static async Task LoadRoot( return data; } - public static async Task LoadCodingRules( - AtcApiGitHubRepositoryClient gitHubRepositoryClient, - List foldersAndFiles, - string repositoryName) - { - ArgumentNullException.ThrowIfNull(gitHubRepositoryClient); - ArgumentNullException.ThrowIfNull(foldersAndFiles); - - var taskRoot = GitHubRepositoryMetadataFileHelper.GetFileByPath( - gitHubRepositoryClient, foldersAndFiles, repositoryName, ".editorconfig"); - - var taskSrc = GitHubRepositoryMetadataFileHelper.GetFileByPath( - gitHubRepositoryClient, foldersAndFiles, repositoryName, "src/.editorconfig"); - - var taskTest = GitHubRepositoryMetadataFileHelper.GetFileByPath( - gitHubRepositoryClient, foldersAndFiles, repositoryName, "test/.editorconfig"); - - await Task.WhenAll(taskRoot, taskSrc, taskTest); - - return new CodingRulesMetadata - { - RawEditorConfigRoot = await taskRoot, - RawEditorConfigSrc = await taskSrc, - RawEditorConfigTest = await taskTest, - }; - } - public static async Task> LoadOpenIssues( AtcApiGitHubRepositoryClient gitHubRepositoryClient, string repositoryName) diff --git a/src/AtcWeb.Domain/GitHub/GitHubRepositoryService.cs b/src/AtcWeb.Domain/GitHub/GitHubRepositoryService.cs index 6c8b2af..f87a1bf 100644 --- a/src/AtcWeb.Domain/GitHub/GitHubRepositoryService.cs +++ b/src/AtcWeb.Domain/GitHub/GitHubRepositoryService.cs @@ -236,18 +236,13 @@ public async Task PopulateMetaDataAdvancedAsync(AtcRepository repository) { ArgumentNullException.ThrowIfNull(repository); - var taskCodingRules = GitHubRepositoryMetadataHelper.LoadCodingRules( - atcApiGitHubRepositoryClient, - repository.FolderAndFilePaths, - repository.Name); - var taskOpenIssues = GitHubRepositoryMetadataHelper.LoadOpenIssues( atcApiGitHubRepositoryClient, repository.Name); var tasks = new List { - taskCodingRules, taskOpenIssues, + taskOpenIssues, }; Task? taskDotnet = null; @@ -275,7 +270,6 @@ public async Task PopulateMetaDataAdvancedAsync(AtcRepository repository) await TaskHelper.WhenAll(tasks); - repository.CodingRules = await taskCodingRules; repository.OpenIssues = await taskOpenIssues; if ("C#".Equals(repository.BaseData.Language, StringComparison.Ordinal)) diff --git a/src/AtcWeb.Domain/GitHub/Models/AtcRepository.cs b/src/AtcWeb.Domain/GitHub/Models/AtcRepository.cs index 881421d..fde24e3 100644 --- a/src/AtcWeb.Domain/GitHub/Models/AtcRepository.cs +++ b/src/AtcWeb.Domain/GitHub/Models/AtcRepository.cs @@ -11,7 +11,6 @@ public AtcRepository(GitHubRepository repository) FolderAndFilePaths = []; OpenIssues = []; Root = new RootMetadata(); - CodingRules = new CodingRulesMetadata(); Dotnet = new DotnetMetadata(); Python = new PythonMetadata(); Wiki = new WikiMetadata(); @@ -34,8 +33,6 @@ public AtcRepository(GitHubRepository repository) public RootMetadata Root { get; set; } - public CodingRulesMetadata CodingRules { get; set; } - public DotnetMetadata? Dotnet { get; set; } public PythonMetadata? Python { get; set; } @@ -48,15 +45,6 @@ public AtcRepository(GitHubRepository repository) public bool HasRootReadme => Root?.HasReadme ?? false; - public bool HasCodingRulesEditorConfigRoot - => CodingRules?.HasRoot ?? false; - - public bool HasCodingRulesEditorConfigSrc - => CodingRules?.HasSrc ?? false; - - public bool HasCodingRulesEditorConfigTest - => CodingRules?.HasTest ?? false; - public bool HasDotnetSolution => Dotnet?.HasSolution ?? false; public bool HasDotnetDirectoryBuildPropsRoot diff --git a/src/AtcWeb.Domain/GitHub/Models/CodingRulesMetadata.cs b/src/AtcWeb.Domain/GitHub/Models/CodingRulesMetadata.cs deleted file mode 100644 index 6d0d808..0000000 --- a/src/AtcWeb.Domain/GitHub/Models/CodingRulesMetadata.cs +++ /dev/null @@ -1,127 +0,0 @@ -namespace AtcWeb.Domain.GitHub.Models; - -public class CodingRulesMetadata -{ - public static Version LatestVersionRoot => new(1, 0, 7); - - public static Version LatestVersionSrc => new(1, 0, 5); - - public static Version LatestVersionTest => new(1, 0, 7); - - public string RawEditorConfigRoot { get; set; } = string.Empty; - - public string RawEditorConfigSrc { get; set; } = string.Empty; - - public string RawEditorConfigTest { get; set; } = string.Empty; - - public bool HasRoot => !string.IsNullOrEmpty(RawEditorConfigRoot); - - public bool HasSrc => !string.IsNullOrEmpty(RawEditorConfigSrc); - - public bool HasTest => !string.IsNullOrEmpty(RawEditorConfigTest); - - public bool IsLatestVersionRoot - => IsLatestVersion(LatestVersionRoot, RawEditorConfigRoot); - - public bool IsLatestVersionSrc - => IsLatestVersion(LatestVersionSrc, RawEditorConfigSrc); - - public bool IsLatestVersionTest - => IsLatestVersion(LatestVersionTest, RawEditorConfigTest); - - public Version GetCurrentVersionRoot() - => GetCurrentVersion(RawEditorConfigRoot); - - public Version GetCurrentVersionSrc() - => GetCurrentVersion(RawEditorConfigSrc); - - public Version GetCurrentVersionTest() - => GetCurrentVersion(RawEditorConfigTest); - - public List GetLocalSuppressRulesRoot() - => GetLocalSuppressRules(RawEditorConfigRoot); - - public List GetLocalSuppressRulesSrc() - => GetLocalSuppressRules(RawEditorConfigSrc); - - public List GetLocalSuppressRulesTest() - => GetLocalSuppressRules(RawEditorConfigTest); - - private static Version GetCurrentVersion(string rawText) - { - if (string.IsNullOrEmpty(rawText)) - { - return new Version(); - } - - var lines = rawText.Split(Environment.NewLine); - foreach (var line in lines) - { - if (!line.StartsWith("# Version: ", StringComparison.Ordinal)) - { - continue; - } - - var s = line.Replace("# Version: ", string.Empty, StringComparison.Ordinal).Trim(); - if (Version.TryParse(s, out var version)) - { - return version; - } - } - - return new Version(); - } - - private static bool IsLatestVersion( - Version latestVersion, - string rawText) - { - if (string.IsNullOrEmpty(rawText)) - { - return false; - } - - var version = GetCurrentVersion(rawText); - return version == latestVersion || version.GreaterThan(latestVersion); - } - - private static List GetLocalSuppressRules(string rawText) - { - var list = new List(); - if (string.IsNullOrEmpty(rawText)) - { - return list; - } - - var lines = rawText.Split(Environment.NewLine); - var isInCustomCodeAnalyzersRules = false; - foreach (var line in lines) - { - if (line.StartsWith("# Custom - Code Analyzers Rules", StringComparison.Ordinal)) - { - isInCustomCodeAnalyzersRules = true; - continue; - } - - if (!isInCustomCodeAnalyzersRules || !line.StartsWith("dotnet_diagnostic.", StringComparison.Ordinal)) - { - continue; - } - - var s = line.Replace("dotnet_diagnostic.", string.Empty, StringComparison.Ordinal); - var ruleId = s.Substring(0, s.IndexOf('.', StringComparison.Ordinal)); - var comment = string.Empty; - var sa = s.Split('#', StringSplitOptions.RemoveEmptyEntries); - if (sa.Length == 2) - { - comment = sa[1].Trim(); - } - - list.Add(new KeyValueItem(ruleId, comment)); - } - - return list - .OrderBy(x => x.Key, StringComparer.Ordinal) - .ToList(); - } -} \ No newline at end of file diff --git a/src/AtcWeb.Domain/GlobalUsings.cs b/src/AtcWeb.Domain/GlobalUsings.cs index 8d520b9..5231fb3 100644 --- a/src/AtcWeb.Domain/GlobalUsings.cs +++ b/src/AtcWeb.Domain/GlobalUsings.cs @@ -6,13 +6,13 @@ global using System.Text.RegularExpressions; global using Atc; -global using Atc.Data.Models; global using Atc.DotNet; global using Atc.DotNet.Models; global using Atc.Helpers; global using Atc.Serialization; global using AtcWeb.Domain.AtcApi; global using AtcWeb.Domain.AtcApi.Models; +global using AtcWeb.Domain.AtcApi.Models.Compliance; global using AtcWeb.Domain.Caching; global using AtcWeb.Domain.Data; global using AtcWeb.Domain.Data.Models; diff --git a/src/AtcWeb/Components/Compliance/ComplianceCardsGrid.razor b/src/AtcWeb/Components/Compliance/ComplianceCardsGrid.razor new file mode 100644 index 0000000..8bbe2a1 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceCardsGrid.razor @@ -0,0 +1,33 @@ +@namespace AtcWeb.Components.Compliance + + \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceCardsGrid.razor.cs b/src/AtcWeb/Components/Compliance/ComplianceCardsGrid.razor.cs new file mode 100644 index 0000000..57c9586 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceCardsGrid.razor.cs @@ -0,0 +1,14 @@ +namespace AtcWeb.Components.Compliance; + +public partial class ComplianceCardsGrid : ComponentBase +{ + [Parameter] + public IReadOnlyList Summaries { get; set; } = []; + + private static Color HealthColor(HealthStatus h) => h switch + { + HealthStatus.Ok => Color.Success, + HealthStatus.Warning => Color.Warning, + _ => Color.Error, + }; +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceDashboardRowDetail.razor b/src/AtcWeb/Components/Compliance/ComplianceDashboardRowDetail.razor new file mode 100644 index 0000000..470bb40 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceDashboardRowDetail.razor @@ -0,0 +1,38 @@ +@namespace AtcWeb.Components.Compliance + +@if (Summary is not null) +{ +
+ + + Frameworks +
src: @JoinOrDash(Summary.Detail.SrcFrameworks)
+
test: @JoinOrDash(Summary.Detail.TestFrameworks)
+
sample: @JoinOrDash(Summary.Detail.SampleFrameworks)
+
+ + Analyzer packages + @if (Summary.Detail.AnalyzerPackages.Count == 0) + { + None pinned + } + else + { + foreach (var p in Summary.Detail.AnalyzerPackages) + { +
@p.PackageId @p.Version
+ } + } +
+ + Open issues +
Count: @Summary.OpenIssuesCount
+
Oldest: @FormatDate(Summary.OldestOpenIssueAt)
+
Newest: @FormatDate(Summary.NewestOpenIssueAt)
+ + Open full repo page + +
+
+
+} \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceDashboardRowDetail.razor.cs b/src/AtcWeb/Components/Compliance/ComplianceDashboardRowDetail.razor.cs new file mode 100644 index 0000000..2758825 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceDashboardRowDetail.razor.cs @@ -0,0 +1,13 @@ +namespace AtcWeb.Components.Compliance; + +public partial class ComplianceDashboardRowDetail : ComponentBase +{ + [Parameter] + public RepositoryComplianceSummary? Summary { get; set; } + + private static string JoinOrDash(IReadOnlyList values) + => values.Count == 0 ? "–" : string.Join(", ", values); + + private static string FormatDate(DateTimeOffset? d) + => d?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "–"; +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceDashboardTable.razor b/src/AtcWeb/Components/Compliance/ComplianceDashboardTable.razor new file mode 100644 index 0000000..1e081f2 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceDashboardTable.razor @@ -0,0 +1,97 @@ +@namespace AtcWeb.Components.Compliance + + + + + + + @context.Item.Name + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceDashboardTable.razor.cs b/src/AtcWeb/Components/Compliance/ComplianceDashboardTable.razor.cs new file mode 100644 index 0000000..00eade6 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceDashboardTable.razor.cs @@ -0,0 +1,188 @@ +namespace AtcWeb.Components.Compliance; + +public partial class ComplianceDashboardTable : ComponentBase +{ + [Parameter] + public IReadOnlyList Summaries { get; set; } = []; + + private static bool IsDotnet(RepositoryComplianceSummary s) + => string.Equals(s.Language, "C#", StringComparison.Ordinal); + + private static string Bool(bool ok) => ok ? "✓" : "✗"; + + private static ChipState EditorConfigState(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return ChipState.NotApplicable; + } + + var ec = s.Signals.EditorConfigStatus; + var latestCount = + (ec.RootIsLatest ? 1 : 0) + + (ec.SrcIsLatest ? 1 : 0) + + (ec.TestIsLatest ? 1 : 0); + + return latestCount switch + { + 3 => ChipState.Ok, + 0 => ChipState.Error, + _ => ChipState.Warning, + }; + } + + private static string EditorConfigLabel(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return "N/A"; + } + + var ec = s.Signals.EditorConfigStatus; + return $"{Bool(ec.RootIsLatest)}/{Bool(ec.SrcIsLatest)}/{Bool(ec.TestIsLatest)}"; + } + + private static ChipState UpdaterState(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return ChipState.NotApplicable; + } + + if (!s.Signals.UpdaterPresent) + { + return ChipState.Warning; + } + + return s.Signals.UpdaterTargetIsLatest ? ChipState.Ok : ChipState.Warning; + } + + private static string UpdaterLabel(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return "N/A"; + } + + return s.Signals.UpdaterProjectTarget ?? "–"; + } + + private static ChipState LangVerState(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return ChipState.NotApplicable; + } + + return s.Signals.GlobalLangVersionIsLatest ? ChipState.Ok : ChipState.Warning; + } + + private static string LangVerLabel(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return "N/A"; + } + + return s.Signals.GlobalLangVersion ?? "–"; + } + + private static ChipState TfmState(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return ChipState.NotApplicable; + } + + return s.Signals.GlobalTargetFrameworkIsLatest ? ChipState.Ok : ChipState.Error; + } + + private static string TfmLabel(RepositoryComplianceSummary s) + { + if (!IsDotnet(s)) + { + return "N/A"; + } + + return s.Signals.GlobalTargetFramework ?? "–"; + } + + private static ChipState XunitState(XunitV3Status status) => status switch + { + XunitV3Status.Yes => ChipState.Ok, + XunitV3Status.No => ChipState.Warning, + _ => ChipState.NotApplicable, + }; + + private static ChipState WorkflowsState(RepositoryComplianceSummary s) + { + var w = s.Signals.WorkflowsStatus; + if (w.HasJavaSetup) + { + return ChipState.Error; + } + + if (!IsDotnet(s)) + { + return ChipState.NotApplicable; + } + + if (!w.CheckoutIsLatest || !w.SetupDotnetIsLatest || !w.DotnetVersionIsLatest) + { + return ChipState.Warning; + } + + return ChipState.Ok; + } + + private static string WorkflowsLabel(RepositoryComplianceSummary s) + { + var w = s.Signals.WorkflowsStatus; + if (w.HasJavaSetup) + { + return "Java!"; + } + + if (!IsDotnet(s)) + { + return "N/A"; + } + + if (!w.CheckoutIsLatest) + { + return "co + + + + C# + Python + + + + @foreach (var cat in Categories) + { + @cat + } + + + + All green + Has warning + Has error + + + + + + None + Category + Health + Language + + \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceFilterBar.razor.cs b/src/AtcWeb/Components/Compliance/ComplianceFilterBar.razor.cs new file mode 100644 index 0000000..d206525 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceFilterBar.razor.cs @@ -0,0 +1,40 @@ +namespace AtcWeb.Components.Compliance; + +public partial class ComplianceFilterBar : ComponentBase +{ + [Parameter] + public ComplianceFilterState State { get; set; } = new(); + + [Parameter] + public EventCallback StateChanged { get; set; } + + [Parameter] + public IReadOnlyList Categories { get; set; } = []; + + [Parameter] + public string GroupBy { get; set; } = "None"; + + [Parameter] + public EventCallback GroupByChanged { get; set; } + + private Task OnSearchChanged(string value) + => Update(s => s.SearchText = value); + + private Task OnLanguageChanged(string value) + => Update(s => s.Language = value); + + private Task OnCategoryChanged(string value) + => Update(s => s.Category = value); + + private Task OnHealthChanged(HealthStatus? value) + => Update(s => s.Health = value); + + private Task OnGroupByChanged(string value) + => GroupByChanged.InvokeAsync(value); + + private Task Update(Action mutate) + { + mutate(State); + return StateChanged.InvokeAsync(State); + } +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceKpiStrip.razor b/src/AtcWeb/Components/Compliance/ComplianceKpiStrip.razor new file mode 100644 index 0000000..60a6302 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceKpiStrip.razor @@ -0,0 +1,15 @@ +@namespace AtcWeb.Components.Compliance + +
+ + + + + + + + +
\ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceKpiStrip.razor.cs b/src/AtcWeb/Components/Compliance/ComplianceKpiStrip.razor.cs new file mode 100644 index 0000000..9584baa --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceKpiStrip.razor.cs @@ -0,0 +1,27 @@ +namespace AtcWeb.Components.Compliance; + +public partial class ComplianceKpiStrip : ComponentBase +{ + [Parameter] + public IReadOnlyList Summaries { get; set; } = []; + + private int Total => Summaries.Count; + + private int CountByLanguage(string lang) + => Summaries.Count(s => string.Equals(s.Language, lang, StringComparison.Ordinal)); + + private string PercentText( + Func predicate) + { + if (Summaries.Count == 0) + { + return "0%"; + } + + var n = Summaries.Count(predicate); + var pct = n * 100 / Summaries.Count; + return string.Create( + CultureInfo.InvariantCulture, + $"{pct}% ({n})"); + } +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceStatusChip.razor b/src/AtcWeb/Components/Compliance/ComplianceStatusChip.razor new file mode 100644 index 0000000..3a4f5c7 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceStatusChip.razor @@ -0,0 +1,12 @@ +@namespace AtcWeb.Components.Compliance + + + + @Label + + \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/ComplianceStatusChip.razor.cs b/src/AtcWeb/Components/Compliance/ComplianceStatusChip.razor.cs new file mode 100644 index 0000000..92b35a0 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/ComplianceStatusChip.razor.cs @@ -0,0 +1,37 @@ +namespace AtcWeb.Components.Compliance; + +public enum ChipState +{ + Ok, + Warning, + Error, + NotApplicable, +} + +public partial class ComplianceStatusChip : ComponentBase +{ + [Parameter] + public ChipState State { get; set; } = ChipState.Ok; + + [Parameter] + public string Label { get; set; } = string.Empty; + + [Parameter] + public string Tooltip { get; set; } = string.Empty; + + private Color ChipColor => State switch + { + ChipState.Ok => Color.Success, + ChipState.Warning => Color.Warning, + ChipState.Error => Color.Error, + _ => Color.Default, + }; + + private string Icon => State switch + { + ChipState.Ok => Icons.Material.Filled.CheckCircle, + ChipState.Warning => Icons.Material.Filled.Warning, + ChipState.Error => Icons.Material.Filled.Error, + _ => Icons.Material.Filled.Remove, + }; +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Compliance/KpiTile.razor b/src/AtcWeb/Components/Compliance/KpiTile.razor new file mode 100644 index 0000000..1aca678 --- /dev/null +++ b/src/AtcWeb/Components/Compliance/KpiTile.razor @@ -0,0 +1,14 @@ +@namespace AtcWeb.Components.Compliance + +
+
@Value
+
@Label
+
+ +@code { + [Parameter] + public string Label { get; set; } = string.Empty; + + [Parameter] + public string Value { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsActionList.razor b/src/AtcWeb/Components/Insights/InsightsActionList.razor new file mode 100644 index 0000000..48bba6c --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsActionList.razor @@ -0,0 +1,24 @@ +@namespace AtcWeb.Components.Insights + +@if (Groups.Count == 0) +{ + All repos look healthy. Nothing to do. +} +else +{ + @foreach (var group in Groups) + { + @group.Category (@group.Items.Count.ToString(CultureInfo.InvariantCulture)) + + @foreach (var item in group.Items) + { + + @item.Repo.Name + @string.Join(" · ", item.FailingRules) + + } + + } +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsActionList.razor.cs b/src/AtcWeb/Components/Insights/InsightsActionList.razor.cs new file mode 100644 index 0000000..5624418 --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsActionList.razor.cs @@ -0,0 +1,129 @@ +namespace AtcWeb.Components.Insights; + +public partial class InsightsActionList : ComponentBase +{ + [Parameter] + public IReadOnlyList Summaries { get; set; } = []; + + protected sealed record ActionItem( + RepositoryComplianceSummary Repo, + HealthStatus Health, + IReadOnlyList FailingRules); + + protected sealed record ActionGroup( + string Category, + IReadOnlyList Items); + + protected List Groups { get; private set; } = []; + + protected override void OnParametersSet() + { + var items = Summaries + .Select(s => new + { + Repo = s, + Health = ComplianceHealth.Compute(s), + Rules = ComputeFailingRules(s), + }) + .Where(x => x.Health != HealthStatus.Ok) + .Select(x => new ActionItem(x.Repo, x.Health, x.Rules)) + .ToList(); + + Groups = items + .GroupBy(i => RepositoryCategoryHelper.GetCategory(i.Repo.Name), StringComparer.Ordinal) + .OrderBy(g => RepositoryCategoryHelper.GetSortOrder(g.Key)) + .Select(g => new ActionGroup(g.Key, g.OrderBy(x => x.Repo.Name, StringComparer.Ordinal).ToList())) + .ToList(); + } + + private static IReadOnlyList ComputeFailingRules( + RepositoryComplianceSummary s) + { + var rules = new List(); + if (!s.Signals.LicenseIsMit) + { + rules.Add("License not MIT"); + } + + if (s.Signals.WorkflowsStatus.HasJavaSetup) + { + rules.Add("setup-java in workflow"); + } + + if (!s.Signals.HasGoodReadme) + { + rules.Add("README too thin"); + } + + if (!s.Signals.HomepageIsAtcWeb) + { + rules.Add("Homepage URL"); + } + + if (string.Equals(s.Language, "C#", StringComparison.Ordinal)) + { + if (!s.Signals.UpdaterPresent || !s.Signals.UpdaterTargetIsLatest) + { + rules.Add("updater not DotNet10"); + } + + if (!s.Signals.EditorConfigStatus.RootIsLatest || + !s.Signals.EditorConfigStatus.SrcIsLatest || + !s.Signals.EditorConfigStatus.TestIsLatest) + { + rules.Add(".editorconfig behind"); + } + + if (!s.Signals.GlobalLangVersionIsLatest) + { + rules.Add("LangVersion behind"); + } + + if (!s.Signals.GlobalTargetFrameworkIsLatest) + { + rules.Add("TFM not net10.0"); + } + + if (s.Signals.XunitV3Status == XunitV3Status.No) + { + rules.Add("xUnit not v3"); + } + + if (!s.Signals.WorkflowsStatus.CheckoutIsLatest) + { + rules.Add("checkout < v6"); + } + + if (!s.Signals.WorkflowsStatus.SetupDotnetIsLatest) + { + rules.Add("setup-dotnet < v5"); + } + + if (!s.Signals.WorkflowsStatus.DotnetVersionIsLatest) + { + rules.Add(".NET version < 10"); + } + + if (!s.Signals.ReleasePleasePresent) + { + rules.Add("no release-please"); + } + } + + return rules; + } + + private static string StatusIcon(HealthStatus h) => h switch + { + HealthStatus.Error => Icons.Material.Filled.Error, + HealthStatus.Warning => Icons.Material.Filled.Warning, + _ => Icons.Material.Filled.CheckCircle, + }; + + private static Color StatusColor(HealthStatus h) => h switch + { + HealthStatus.Error => Color.Error, + HealthStatus.Warning => Color.Warning, + _ => Color.Success, + }; +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsAdoptionCharts.razor b/src/AtcWeb/Components/Insights/InsightsAdoptionCharts.razor new file mode 100644 index 0000000..928cbec --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsAdoptionCharts.razor @@ -0,0 +1,49 @@ +@namespace AtcWeb.Components.Insights + + + + .NET TFM distribution + @if (TfmLabels.Length > 0) + { + + } + else + { + No TFM data found. + } + + + Atc.Analyzer versions + @if (AnalyzerLabels.Length > 0) + { + + } + else + { + No Atc.Analyzer references found. + } + + + Languages + @if (LanguageLabels.Length > 0) + { + + } + else + { + No language data found. + } + + \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsAdoptionCharts.razor.cs b/src/AtcWeb/Components/Insights/InsightsAdoptionCharts.razor.cs new file mode 100644 index 0000000..d137e5f --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsAdoptionCharts.razor.cs @@ -0,0 +1,71 @@ +namespace AtcWeb.Components.Insights; + +public partial class InsightsAdoptionCharts : ComponentBase +{ + [Parameter] + public IReadOnlyList Summaries { get; set; } = []; + + protected List> TfmSeries { get; private set; } = []; + + protected string[] TfmLabels { get; private set; } = []; + + protected string[] AnalyzerLabels { get; private set; } = []; + + protected List> AnalyzerSeries { get; private set; } = []; + + protected List> LanguageSeries { get; private set; } = []; + + protected string[] LanguageLabels { get; private set; } = []; + + protected override void OnParametersSet() + { + var tfms = Summaries + .Select(s => s.Signals.GlobalTargetFramework) + .Where(v => !string.IsNullOrEmpty(v)) + .Cast() + .GroupBy(v => v, StringComparer.Ordinal) + .OrderByDescending(g => g.Count()) + .ToList(); + TfmLabels = tfms.Select(g => g.Key).ToArray(); + TfmSeries = + [ + new ChartSeries + { + Name = "Repos", + Data = tfms.Select(g => (double)g.Count()).ToArray(), + }, + ]; + + var analyzerVersions = Summaries + .SelectMany(s => s.Detail.AnalyzerPackages) + .Where(p => string.Equals(p.PackageId, "Atc.Analyzer", StringComparison.Ordinal)) + .GroupBy(p => p.Version, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .ToList(); + AnalyzerLabels = analyzerVersions.Select(g => g.Key).ToArray(); + AnalyzerSeries = + [ + new ChartSeries + { + Name = "Repos", + Data = analyzerVersions.Select(g => (double)g.Count()).ToArray(), + }, + ]; + + var langs = Summaries + .GroupBy( + s => string.IsNullOrEmpty(s.Language) ? "Unknown" : s.Language!, + StringComparer.Ordinal) + .OrderByDescending(g => g.Count()) + .ToList(); + LanguageLabels = langs.Select(g => g.Key).ToArray(); + LanguageSeries = + [ + new ChartSeries + { + Name = "Repos", + Data = langs.Select(g => (double)g.Count()).ToArray(), + }, + ]; + } +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsHealthByCategoryChart.razor b/src/AtcWeb/Components/Insights/InsightsHealthByCategoryChart.razor new file mode 100644 index 0000000..794ae5c --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsHealthByCategoryChart.razor @@ -0,0 +1,14 @@ +@namespace AtcWeb.Components.Insights + +@if (XAxis.Length > 0) +{ + +} +else +{ + No data. +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsHealthByCategoryChart.razor.cs b/src/AtcWeb/Components/Insights/InsightsHealthByCategoryChart.razor.cs new file mode 100644 index 0000000..fc09067 --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsHealthByCategoryChart.razor.cs @@ -0,0 +1,51 @@ +namespace AtcWeb.Components.Insights; + +public partial class InsightsHealthByCategoryChart : ComponentBase +{ + [Parameter] + public IReadOnlyList Summaries { get; set; } = []; + + protected string[] XAxis { get; private set; } = []; + + protected List> Series { get; private set; } = []; + + protected override void OnParametersSet() + { + var byCategory = Summaries + .GroupBy(s => RepositoryCategoryHelper.GetCategory(s.Name), StringComparer.Ordinal) + .OrderBy(g => RepositoryCategoryHelper.GetSortOrder(g.Key)) + .ToList(); + + XAxis = byCategory.Select(g => g.Key).ToArray(); + + var ok = new double[XAxis.Length]; + var warn = new double[XAxis.Length]; + var err = new double[XAxis.Length]; + + for (var i = 0; i < byCategory.Count; i++) + { + foreach (var s in byCategory[i]) + { + switch (ComplianceHealth.Compute(s)) + { + case HealthStatus.Ok: + ok[i]++; + break; + case HealthStatus.Warning: + warn[i]++; + break; + case HealthStatus.Error: + err[i]++; + break; + } + } + } + + Series = + [ + new ChartSeries { Name = "OK", Data = ok }, + new ChartSeries { Name = "Warning", Data = warn }, + new ChartSeries { Name = "Error", Data = err }, + ]; + } +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsKpiTiles.razor b/src/AtcWeb/Components/Insights/InsightsKpiTiles.razor new file mode 100644 index 0000000..25ae488 --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsKpiTiles.razor @@ -0,0 +1,14 @@ +@namespace AtcWeb.Components.Insights + +
+ + + + + + + +
\ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/InsightsKpiTiles.razor.cs b/src/AtcWeb/Components/Insights/InsightsKpiTiles.razor.cs new file mode 100644 index 0000000..eb7972b --- /dev/null +++ b/src/AtcWeb/Components/Insights/InsightsKpiTiles.razor.cs @@ -0,0 +1,19 @@ +namespace AtcWeb.Components.Insights; + +public partial class InsightsKpiTiles : ComponentBase +{ + [Parameter] + public IReadOnlyList Summaries { get; set; } = []; + + private int Total => Summaries.Count; + + private int Pct(Func predicate) + { + if (Summaries.Count == 0) + { + return 0; + } + + return Summaries.Count(predicate) * 100 / Summaries.Count; + } +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Insights/Tile.razor b/src/AtcWeb/Components/Insights/Tile.razor new file mode 100644 index 0000000..57c9247 --- /dev/null +++ b/src/AtcWeb/Components/Insights/Tile.razor @@ -0,0 +1,24 @@ +@namespace AtcWeb.Components.Insights + +
+ @if (Percent is { } p) + { +
@p.ToString(CultureInfo.InvariantCulture)%
+ } + else + { +
@Value
+ } +
@Label
+
+ +@code { + [Parameter] + public string Label { get; set; } = string.Empty; + + [Parameter] + public string Value { get; set; } = string.Empty; + + [Parameter] + public int? Percent { get; set; } +} \ No newline at end of file diff --git a/src/AtcWeb/Components/Repository/RepositoryCodingRules.razor b/src/AtcWeb/Components/Repository/RepositoryCodingRules.razor deleted file mode 100644 index 925fb69..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryCodingRules.razor +++ /dev/null @@ -1,134 +0,0 @@ -@using AtcWeb.Domain.GitHub.Models -@using AtcWeb.State - -@if (StateContainer is not null && - Repository is not null) -{ - - - - Config level -   - - - - - - Root - - - @if (Repository.CodingRules.HasRoot) - { - if (Repository.CodingRules.IsLatestVersionRoot) - { - - .editorconfig is up to date - - } - else - { - - - .editorconfig is not up to date - - - } - - - } - else - { - - .editorconfig is missing - - } - - - - - - Src - - - @if (Repository.CodingRules.HasSrc) - { - if (Repository.CodingRules.IsLatestVersionSrc) - { - - .editorconfig is up to date - - } - else - { - - - .editorconfig is not up to date - - - } - - - } - else - { - - .editorconfig is missing - - } - - - - - - Test - - - @if (Repository.CodingRules.HasTest) - { - if (Repository.CodingRules.IsLatestVersionTest) - { - - .editorconfig is up to date - - } - else - { - - - .editorconfig is not up to date - - - } - - - } - else - { - - .editorconfig is missing - - } - - - - -} diff --git a/src/AtcWeb/Components/Repository/RepositoryCodingRules.razor.cs b/src/AtcWeb/Components/Repository/RepositoryCodingRules.razor.cs deleted file mode 100644 index 67c798c..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryCodingRules.razor.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AtcWeb.Components.Repository; - -public partial class RepositoryCodingRules -{ - [Inject] - private StateContainer? StateContainer { get; set; } - - [Parameter] - public AtcRepository? Repository { get; set; } -} \ No newline at end of file diff --git a/src/AtcWeb/Components/Repository/RepositoryCodingRulesLocalSuppressRules.razor b/src/AtcWeb/Components/Repository/RepositoryCodingRulesLocalSuppressRules.razor deleted file mode 100644 index bb6292a..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryCodingRulesLocalSuppressRules.razor +++ /dev/null @@ -1,54 +0,0 @@ -@using Atc.Data.Models -@using AtcWeb.State - -@if (StateContainer is not null) -{ - @if (LocalSuppressRules.Count > 0) - { - if (LocalSuppressRules.Count > 5) - { - - @LocalSuppressRules.Count local suppress rules. - - } - - - @foreach (var suppressRule in LocalSuppressRules) - { - - - - - } -
@suppressRule.Key - @if (string.IsNullOrEmpty(suppressRule.Value) || - suppressRule.Value.Length < 5 || - suppressRule.Value.StartsWith(suppressRule.Key, StringComparison.OrdinalIgnoreCase)) - { - - @suppressRule.Value - - } - else - { - - @suppressRule.Value - - } -
- } - else - { - - No local suppress rules. - - } -} diff --git a/src/AtcWeb/Components/Repository/RepositoryCodingRulesLocalSuppressRules.razor.cs b/src/AtcWeb/Components/Repository/RepositoryCodingRulesLocalSuppressRules.razor.cs deleted file mode 100644 index bfe213b..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryCodingRulesLocalSuppressRules.razor.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AtcWeb.Components.Repository; - -public partial class RepositoryCodingRulesLocalSuppressRules -{ - [Inject] - private StateContainer? StateContainer { get; set; } - - [Parameter] - public List LocalSuppressRules { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/AtcWeb/Components/Repository/RepositoryDescription.razor b/src/AtcWeb/Components/Repository/RepositoryDescription.razor deleted file mode 100644 index 4ef9668..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryDescription.razor +++ /dev/null @@ -1,14 +0,0 @@ -@using AtcWeb.Domain.GitHub.Models -@using AtcWeb.Styles - -@if (Repository is not null) -{ - - Repository.BaseData.Language - - @Repository.Description - - -} diff --git a/src/AtcWeb/Components/Repository/RepositoryDescription.razor.cs b/src/AtcWeb/Components/Repository/RepositoryDescription.razor.cs deleted file mode 100644 index 6b7fa47..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryDescription.razor.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace AtcWeb.Components.Repository; - -public partial class RepositoryDescription -{ - [Parameter] - public AtcRepository? Repository { get; set; } - - private string IconSrc - => Repository is null - ? string.Empty - : ImageHelper.GetProgramIconPathForLanguage(Repository.BaseData.Language); -} \ No newline at end of file diff --git a/src/AtcWeb/Components/Repository/RepositoryDotNetProjects.razor b/src/AtcWeb/Components/Repository/RepositoryDotNetProjects.razor deleted file mode 100644 index 336130b..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryDotNetProjects.razor +++ /dev/null @@ -1,42 +0,0 @@ -@using AtcWeb.Domain.GitHub.Models -@using AtcWeb.State - -@if (StateContainer is not null && - Repository?.Dotnet is not null) -{ - - - - Project name -   - - - - @foreach (var project in Repository.Dotnet.Projects) - { - - - @project.Name - - - - - - - - - - - - - - - -
Compiler settings:
Analyzer settings:
Package reference:
- - - } - -
-} diff --git a/src/AtcWeb/Components/Repository/RepositoryDotNetProjects.razor.cs b/src/AtcWeb/Components/Repository/RepositoryDotNetProjects.razor.cs deleted file mode 100644 index fa34f50..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryDotNetProjects.razor.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AtcWeb.Components.Repository; - -public partial class RepositoryDotNetProjects -{ - [Inject] - private StateContainer? StateContainer { get; set; } - - [Parameter] - public AtcRepository? Repository { get; set; } -} \ No newline at end of file diff --git a/src/AtcWeb/Components/Repository/RepositoryDotNetSolution.razor b/src/AtcWeb/Components/Repository/RepositoryDotNetSolution.razor deleted file mode 100644 index 1538d0c..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryDotNetSolution.razor +++ /dev/null @@ -1,75 +0,0 @@ -@using AtcWeb.Domain.GitHub.Models -@using AtcWeb.State -@using AtcWeb.Domain.Data - -@if (StateContainer is not null && - Repository?.Dotnet?.SolutionMetadata is not null) -{ - - - - Area -   - - - - - - File Format - - - - - - - -
Version@Repository.Dotnet.SolutionMetadata.FileFormatVersion
- - - - - Visual Studio - - - - - - - - - - - - - - - - - - - - -
Name - @if (Repository.Dotnet.IsVisualStudioNameInAcceptedVersion) - { - - - @Repository.Dotnet.SolutionMetadata.VisualStudioName - - - } - else - { - - - @Repository.Dotnet.SolutionMetadata.VisualStudioName - - - } -
Version Number@Repository.Dotnet.SolutionMetadata.VisualStudioVersionNumber
Version@Repository.Dotnet.SolutionMetadata.VisualStudioVersion
Minimum Version@Repository.Dotnet.SolutionMetadata.MinimumVisualStudioVersion
- - - -
-} diff --git a/src/AtcWeb/Components/Repository/RepositoryDotNetSolution.razor.cs b/src/AtcWeb/Components/Repository/RepositoryDotNetSolution.razor.cs deleted file mode 100644 index a90b7f3..0000000 --- a/src/AtcWeb/Components/Repository/RepositoryDotNetSolution.razor.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AtcWeb.Components.Repository; - -public partial class RepositoryDotNetSolution -{ - [Inject] - private StateContainer? StateContainer { get; set; } - - [Parameter] - public AtcRepository? Repository { get; set; } -} \ No newline at end of file diff --git a/src/AtcWeb/Components/Repository/RepositorySummery.razor b/src/AtcWeb/Components/Repository/RepositorySummery.razor deleted file mode 100644 index 21838bd..0000000 --- a/src/AtcWeb/Components/Repository/RepositorySummery.razor +++ /dev/null @@ -1,174 +0,0 @@ -@using AtcWeb.Domain.GitHub.Models -@using Atc -@using AtcWeb.State - -@if (StateContainer is not null && - Repository is not null) -{ - - - - Last updated: - @Repository.BaseData.UpdatedAt.ToString("g") - - - Created: - @Repository.BaseData.CreatedAt.ToString("g") - - - Pushed: - @Repository.BaseData.PushedAt?.ToString("g") - - - Open issues: - - @if (Repository.OpenIssues.Count > 0) - { -
    -
  1. - @if (Repository.GetOpenIssuesOldestState(1, 3) == LogCategoryType.Warning) - { - - - Newest created: @Repository.GetOpenIssuesNewest()?.ToString("g")   - - - } - else if (Repository.GetOpenIssuesOldestState(1, 3) == LogCategoryType.Error) - { - - - Newest created: @Repository.GetOpenIssuesNewest()?.ToString("g")   - - - } - else - { - - Newest created: @Repository.GetOpenIssuesNewest()?.ToString("g")   - - } -
  2. -
  3. - @if (Repository.GetOpenIssuesOldestState(3, 6) == LogCategoryType.Warning) - { - - - Oldest created: @Repository.GetOpenIssuesNewest()?.ToString("g")   - - - } - else if (Repository.GetOpenIssuesOldestState(3, 6) == LogCategoryType.Error) - { - - - Oldest created: @Repository.GetOpenIssuesNewest()?.ToString("g")   - - - } - else - { - - Oldest created: @Repository.GetOpenIssuesNewest()?.ToString("g")   - - } -
  4. -
- } - else - { - - None - - } - - - - Homepage: - - @if (string.IsNullOrEmpty(Repository.BaseData.Homepage)) - { - - - The homepage is missing! - - - } - else if (!Repository.BaseData.Homepage.Equals($"https://atc-net.github.io/repository/{Repository.Name}", StringComparison.Ordinal)) - { - - - The homepage is wrong url! - - - } - else - { - - - Link - - - } - - - - License: - - @if (string.IsNullOrEmpty(Repository.BaseData.License?.Key)) - { - - - The MIT-license is missing! - - - } - else if (!Repository.BaseData.License.Key.Equals("mit", StringComparison.Ordinal)) - { - - - The license is wrong - should be set to MIT-license! - - - } - else - { - - MIT - - } - - - -
-} diff --git a/src/AtcWeb/Components/Repository/RepositorySummery.razor.cs b/src/AtcWeb/Components/Repository/RepositorySummery.razor.cs deleted file mode 100644 index 3908cc6..0000000 --- a/src/AtcWeb/Components/Repository/RepositorySummery.razor.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace AtcWeb.Components.Repository; - -public partial class RepositorySummery -{ - [Inject] - private StateContainer? StateContainer { get; set; } - - [Parameter] - public AtcRepository? Repository { get; set; } -} \ No newline at end of file diff --git a/src/AtcWeb/GlobalUsings.cs b/src/AtcWeb/GlobalUsings.cs index 8527d91..9776570 100644 --- a/src/AtcWeb/GlobalUsings.cs +++ b/src/AtcWeb/GlobalUsings.cs @@ -4,11 +4,12 @@ global using System.Text.RegularExpressions; global using System.Web; global using Atc; -global using Atc.Data.Models; global using AtcWeb; global using AtcWeb.Domain.AtcApi; global using AtcWeb.Domain.AtcApi.Models; +global using AtcWeb.Domain.AtcApi.Models.Compliance; global using AtcWeb.Domain.Caching; +global using AtcWeb.Domain.Compliance; global using AtcWeb.Domain.GitHub; global using AtcWeb.Domain.GitHub.Models; global using AtcWeb.Extensions; diff --git a/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor b/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor index f830b9b..cbaf42f 100644 --- a/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor +++ b/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor @@ -2,12 +2,12 @@ @inherits RepositoryComplianceOverviewBase - + + SubTitle="Latest ATC compliance signals across all repositories."> - @if (Repositories is null) + @if (Summaries is null) { @@ -15,51 +15,15 @@ } else { - foreach (var repository in Repositories.OrderBy(x => x.Name)) - { - - - - - - - - - - @if ("C#".Equals(repository.BaseData.Language, StringComparison.Ordinal)) - { -
-

Solution

- -
-
-

.NET projects

- -
- } - else if ("Python".Equals(repository.BaseData.Language, StringComparison.Ordinal)) - { -
-

Python projects

- TODO: Data rendering is not implemented yet. -
- } - - @if (!repository.Name.Equals("atc-docs") || - !repository.Name.Equals("atc-snippets")) - { -
-

Atc Coding Rules

- -
- } - -
-
- } + + + + + }
\ No newline at end of file diff --git a/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor.cs b/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor.cs index 17442d6..d3a1a4e 100644 --- a/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor.cs +++ b/src/AtcWeb/Pages/Support/RepositoryComplianceOverview.razor.cs @@ -2,15 +2,63 @@ namespace AtcWeb.Pages.Support; public class RepositoryComplianceOverviewBase : ComponentBase { - protected List? Repositories; - [Inject] - protected GitHubRepositoryService RepositoryService { get; set; } + protected AtcApiGitHubRepositoryClient Client { get; set; } = default!; - protected override async Task OnInitializedAsync() + protected List? Summaries { get; private set; } + + protected ComplianceFilterState FilterState { get; set; } = new(); + + protected string GroupBy { get; set; } = "None"; + + protected IReadOnlyList GetFiltered() { - Repositories = await RepositoryService.GetRepositoriesAsync(populateMetaDataBase: true, populateMetaDataAdvanced: true); + if (Summaries is null) + { + return []; + } + return ComplianceFilterEngine + .Apply(Summaries, FilterState) + .Where(s => + string.IsNullOrEmpty(FilterState.Category) || + string.Equals( + RepositoryCategoryHelper.GetCategory(s.Name), + FilterState.Category, + StringComparison.Ordinal)) + .ToList(); + } + + protected IReadOnlyList GetCategoryNames() + { + if (Summaries is null) + { + return []; + } + + return Summaries + .Select(s => RepositoryCategoryHelper.GetCategory(s.Name)) + .Distinct(StringComparer.Ordinal) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + } + + protected override async Task OnInitializedAsync() + { + var (isSuccessful, summaries) = await Client.GetComplianceSummary(); + Summaries = isSuccessful ? summaries : []; await base.OnInitializedAsync(); } + + protected void OnFilterChanged(ComplianceFilterState state) + { + FilterState = state; + StateHasChanged(); + } + + protected void OnGroupByChanged(string value) + { + GroupBy = value; + StateHasChanged(); + } } \ No newline at end of file diff --git a/src/AtcWeb/Pages/Support/RepositoryInsights.razor b/src/AtcWeb/Pages/Support/RepositoryInsights.razor new file mode 100644 index 0000000..eb5693a --- /dev/null +++ b/src/AtcWeb/Pages/Support/RepositoryInsights.razor @@ -0,0 +1,35 @@ +@page "/support/repository-insights" + +@inherits RepositoryInsightsBase + + + + + + @if (Summaries is null) + { + + + + } + else + { + Org Health + +
+ +
+ + + + Tech Adoption + + + + + Needs attention + + } +
+
\ No newline at end of file diff --git a/src/AtcWeb/Pages/Support/RepositoryInsights.razor.cs b/src/AtcWeb/Pages/Support/RepositoryInsights.razor.cs new file mode 100644 index 0000000..a756f7c --- /dev/null +++ b/src/AtcWeb/Pages/Support/RepositoryInsights.razor.cs @@ -0,0 +1,16 @@ +namespace AtcWeb.Pages.Support; + +public class RepositoryInsightsBase : ComponentBase +{ + [Inject] + protected AtcApiGitHubRepositoryClient Client { get; set; } = default!; + + protected List? Summaries { get; private set; } + + protected override async Task OnInitializedAsync() + { + var (isSuccessful, summaries) = await Client.GetComplianceSummary(); + Summaries = isSuccessful ? summaries : []; + await base.OnInitializedAsync(); + } +} \ No newline at end of file diff --git a/src/AtcWeb/Shared/MainLayout.razor b/src/AtcWeb/Shared/MainLayout.razor index c79ce00..bc3e1c6 100644 --- a/src/AtcWeb/Shared/MainLayout.razor +++ b/src/AtcWeb/Shared/MainLayout.razor @@ -48,9 +48,10 @@ News CLI Tools DevOps Playbook - Maintenance + + Compliance overview + Repository insights API rate limits div>table thead>tr>th{border:none}.mud-simple-table>div>table tbody>tr>td{border:none !important}.mud-simple-table.mud-table-bordered>div>table thead>tr>th{border:1px groove var(--mud-palette-table-lines)}.mud-simple-table.mud-table-bordered>div>table tbody>tr>td{border:1px groove var(--mud-palette-table-lines) !important}.atc-simple-table-clean tr>td{border:none;margin:0px !important;padding:4px 0px !important;border:none !important}.atc-simple-table-clean.table-no-padding>tr>td{padding:0px !important}.atc-simple-table-clean-bottom-line>tr>td{border:none;margin:0px !important;padding:4px 0px !important;border:none !important;border-bottom:1px groove var(--mud-palette-table-lines) !important}.atc-simple-table-clean-bottom-line>tr:last-child>td{border:none;margin:0px !important;padding:4px 0px !important;border:none !important}#mudads{margin-bottom:50px;min-height:100px}#carbonads *{margin:initial;padding:initial}#carbonads{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",Helvetica,Arial,sans-serif}#carbonads{display:flex;width:100%;max-width:400px;background-color:var(--mud-palette-surface);box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:var(--mud-default-borderradius);z-index:100;animation:mud-animation-fadein ease .15s}#carbonads a{color:inherit;text-decoration:none}#carbonads a:hover{color:inherit}#carbonads span{position:relative;display:block;overflow:hidden}#carbonads .carbon-wrap{display:flex}#carbonads .carbon-img{display:block;margin:0;line-height:1}#carbonads .carbon-img img{display:block;border-bottom-left-radius:var(--mud-default-borderradius);border-top-left-radius:var(--mud-default-borderradius)}#carbonads .carbon-text{font-size:13px;padding:10px;margin-bottom:16px;line-height:1.5;text-align:left}#carbonads .carbon-poweredby{display:block;padding:6px 8px;text-align:center;text-transform:uppercase;letter-spacing:.5px;font-weight:600;font-size:8px;line-height:1;border-top-left-radius:3px;position:absolute;bottom:0;right:0}.markdown h1,.markdown h2,.markdown h3,.markdown h4,.markdown h5,.markdown h6{margin-top:16px}.markdown a{color:var(--mud-palette-primary)}.markdown p{margin-top:0;margin-bottom:16px}.markdown ul,.markdown ol{padding-left:2em;margin-top:0;margin-bottom:16px}.markdown ul li{list-style-type:disc}.markdown pre{background-color:var(--mud-palette-dark);border-radius:12px}.markdown pre pre{border-radius:0px}.markdown pre>code{display:block;height:100%;margin:8px 0px;padding:16px;color:#ededed;direction:ltr;overflow:auto;font-size:85%;font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;-webkit-font-smoothing:subpixel-antialiased}.markdown p code{margin:0;padding:.2em .4em;border-radius:6px;color:#ededed;background-color:var(--mud-palette-dark);font-size:85%}.markdown table{border-collapse:collapse;border:1px groove var(--mud-palette-table-lines) !important}.markdown table tr th{padding:8px;border:1px groove var(--mud-palette-table-lines)}.markdown table tr td{padding:8px;border:1px groove var(--mud-palette-table-lines)}.atc-codeblock{height:100%;background-color:var(--mud-palette-black);border-radius:12px;padding:16px;overflow:auto;border-left:3px solid #776be7}.atc-codeblock pre{height:100%;color:#ededed;padding:0px;font-size:1em;font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;-webkit-font-smoothing:subpixel-antialiased;direction:ltr}.atc-codeblock .html+.csharp{margin-top:23px}.atc-codeblock .html .htmlTagDelimiter{color:#979797}.atc-codeblock .html .htmlElementName{color:#988ef1}.atc-codeblock .html .htmlAttributeName{color:#27b5b5}.atc-codeblock .html .htmlOperator,.atc-codeblock .html .quot{color:#c8c8c8}.atc-codeblock .html .htmlAttributeValue{color:#ededed}.atc-codeblock .html .htmlLink{color:#61afef;text-decoration:underline}.atc-codeblock .html .enum{color:#b4eb8f;background-color:hsla(0,0%,100%,.15)}.atc-codeblock .html .enumValue,.atc-codeblock .html .sharpVariable{color:#ededed;background-color:hsla(0,0%,100%,.15)}.atc-codeblock .html .keyword{color:#61afef;background-color:hsla(0,0%,100%,.15)}.atc-codeblock .html .atSign{color:#000;background-color:#d2d295}.atc-codeblock .html .comment{color:#57a64a}.atc-codeblock .csharp .atSign{color:#000;background-color:#d2d295}.atc-codeblock .csharp .keyword{color:#569cd6}.atc-codeblock .csharp .string{color:#d69d85}.atc-codeblock .csharp .function{color:#dcdcaa}.atc-codeblock .csharp .class{color:#4ec9b0}.atc-codeblock .csharp .localVar{color:#9cdcfe}.atc-codeblock .csharp .interface{color:#b0d7a3}.atc-codeblock .csharp .number{color:#b0d7a3}.atc-codeblock .csharp .enum{color:#b4eb8f}.atc-codeblock .csharp .comment{color:#57a64a}:root{--atc-gradient-primary: linear-gradient(135deg, #776be7 0%, #ff4081 100%);--atc-gradient-purple: linear-gradient(135deg, #776be7 0%, #9b59b6 100%);--atc-gradient-teal: linear-gradient(135deg, #1ec8a5 0%, #3299ff 100%);--atc-gradient-pink: linear-gradient(135deg, #ff4081 0%, #e84393 100%);--atc-gradient-orange: linear-gradient(135deg, #ffa800 0%, #f64e62 100%);--atc-gradient-blue: linear-gradient(135deg, #3299ff 0%, #776be7 100%);--atc-gradient-green: linear-gradient(135deg, #00b894 0%, #1ec8a5 100%);--atc-gradient-amber: linear-gradient(135deg, #f0932b 0%, #e17055 100%);--atc-gradient-cyan: linear-gradient(135deg, #00cec9 0%, #0984e3 100%);--atc-glow-purple: 0 0 20px rgba(119, 107, 231, 0.15);--atc-glow-pink: 0 0 20px rgba(255, 64, 129, 0.15)}.atc-appbar-frosted{backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border-bottom:1px solid rgba(119,107,231,.15)}.atc-nav-button{text-transform:none !important;font-weight:400 !important;font-size:14px !important;letter-spacing:.02em !important;opacity:.85;transition:opacity .2s ease}.atc-nav-button:hover{opacity:1;background-color:rgba(119,107,231,.08) !important}.accent-card,.accent-card-blue,.accent-card-orange,.accent-card-pink,.accent-card-teal,.accent-card-purple{border-radius:12px;background-color:var(--mud-palette-surface);border-left:3px solid rgba(0,0,0,0);border-image:var(--atc-gradient-primary) 1;border-image-slice:1;transition:box-shadow .3s ease,transform .2s ease}.accent-card:hover,.accent-card-blue:hover,.accent-card-orange:hover,.accent-card-pink:hover,.accent-card-teal:hover,.accent-card-purple:hover{box-shadow:var(--atc-glow-purple);transform:translateY(-2px)}.accent-card-purple{border-image:var(--atc-gradient-purple) 1}.accent-card-purple:hover{box-shadow:0 0 24px rgba(119,107,231,.2)}.accent-card-teal{border-image:var(--atc-gradient-teal) 1}.accent-card-teal:hover{box-shadow:0 0 24px rgba(30,200,165,.2)}.accent-card-pink{border-image:var(--atc-gradient-pink) 1}.accent-card-pink:hover{box-shadow:0 0 24px rgba(255,64,129,.2)}.accent-card-orange{border-image:var(--atc-gradient-orange) 1}.accent-card-orange:hover{box-shadow:0 0 24px rgba(255,168,0,.2)}.accent-card-blue{border-image:var(--atc-gradient-blue) 1}.accent-card-blue:hover{box-shadow:0 0 24px rgba(50,153,255,.2)}.accent-top-card{border-radius:12px;background-color:var(--mud-palette-surface);position:relative;overflow:hidden;transition:box-shadow .3s ease,transform .2s ease}.accent-top-card::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:var(--atc-gradient-primary)}.accent-top-card:hover{box-shadow:var(--atc-glow-purple);transform:translateY(-2px)}.glass-panel{backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border-radius:16px}.mud-theme-dark .glass-panel{background:rgba(22,22,42,.6);border:1px solid rgba(119,107,231,.1)}:not(.mud-theme-dark) .glass-panel{background:hsla(0,0%,100%,.7);border:1px solid rgba(108,92,231,.1)}.glass-panel-light{background:hsla(0,0%,100%,.7);backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(108,92,231,.1);border-radius:16px}.gradient-text{background:var(--atc-gradient-primary);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text}.gradient-divider{height:2px;background:var(--atc-gradient-primary);border:none;border-radius:1px;opacity:.5}.docs-page-content-navigation-drawer .mud-nav-link.active{border-left-color:#776be7}.docs-page-content-navigation-drawer .mud-nav-link.active::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--atc-gradient-primary);border-radius:0 2px 2px 0}.mud-drawer{border-right:1px solid rgba(119,107,231,.08) !important;overflow-x:hidden !important}.mud-paper{border-radius:12px !important}.mud-nav-link{border-radius:8px !important;margin:2px 8px}.mud-nav-link:hover{background-color:rgba(119,107,231,.08) !important}.mud-nav-link.active{background-color:rgba(119,107,231,.12) !important;color:#776be7 !important}:not(.mud-theme-dark) .mud-nav-link:hover{background-color:rgba(119,107,231,.1) !important}:not(.mud-theme-dark) .mud-nav-link.active{background-color:rgba(119,107,231,.15) !important}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:rgba(0,0,0,0)}.mud-theme-dark ::-webkit-scrollbar-thumb,.mud-theme-dark::-webkit-scrollbar-thumb{background:rgba(119,107,231,.3);border-radius:4px}.mud-theme-dark ::-webkit-scrollbar-thumb:hover,.mud-theme-dark::-webkit-scrollbar-thumb:hover{background:rgba(119,107,231,.5)}:not(.mud-theme-dark) ::-webkit-scrollbar-thumb,:not(.mud-theme-dark)::-webkit-scrollbar-thumb{background:rgba(119,107,231,.2);border-radius:4px}:not(.mud-theme-dark) ::-webkit-scrollbar-thumb:hover,:not(.mud-theme-dark)::-webkit-scrollbar-thumb:hover{background:rgba(119,107,231,.4)}.mermaid,pre.mermaid{background:rgba(0,0,0,0) !important;text-align:center;padding:16px 0}.mermaid svg,pre.mermaid svg{max-width:100%;height:auto}.mermaid .node rect,.mermaid .node circle,.mermaid .node polygon,pre.mermaid .node rect,pre.mermaid .node circle,pre.mermaid .node polygon{stroke:#776be7 !important;stroke-width:1.5px !important}.mermaid .edgePath .path,pre.mermaid .edgePath .path{stroke:#776be7 !important;stroke-width:1.5px !important}.mermaid .arrowheadPath,pre.mermaid .arrowheadPath{fill:#776be7 !important}.mud-theme-dark .mermaid .nodeLabel,.mud-theme-dark pre.mermaid .nodeLabel{color:#1a1a2e !important}.mud-theme-dark .mermaid .edgeLabel,.mud-theme-dark pre.mermaid .edgeLabel{color:#c8c6d8 !important;background-color:rgba(0,0,0,0) !important}.mud-theme-dark .mermaid .edgeLabel rect,.mud-theme-dark pre.mermaid .edgeLabel rect{fill:#16162a !important;opacity:.9}.mud-theme-dark .mermaid text,.mud-theme-dark pre.mermaid text{fill:#c8c6d8 !important}.mud-theme-dark .mermaid .cluster rect,.mud-theme-dark pre.mermaid .cluster rect{fill:#16162a !important;stroke:#2a2a4a !important}:not(.mud-theme-dark) .mermaid .nodeLabel,:not(.mud-theme-dark) pre.mermaid .nodeLabel{color:#2d2b42 !important}:not(.mud-theme-dark) .mermaid .edgeLabel,:not(.mud-theme-dark) pre.mermaid .edgeLabel{color:#4a4568 !important;background-color:rgba(0,0,0,0) !important}:not(.mud-theme-dark) .mermaid .edgeLabel rect,:not(.mud-theme-dark) pre.mermaid .edgeLabel rect{fill:#f8f7fc !important;opacity:.9}:not(.mud-theme-dark) .mermaid text,:not(.mud-theme-dark) pre.mermaid text{fill:#4a4568 !important}:not(.mud-theme-dark) .mermaid .cluster rect,:not(.mud-theme-dark) pre.mermaid .cluster rect{fill:#f0eff5 !important;stroke:#ddd9e9 !important}.atc-navmenu::-webkit-scrollbar{width:6px}.atc-navmenu::-webkit-scrollbar-thumb{background:rgba(119,107,231,.15);border-radius:3px}.atc-navmenu::-webkit-scrollbar-thumb:hover{background:rgba(119,107,231,.4)}@keyframes fadeInUp{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}.fade-in-up{animation:fadeInUp .5s ease-out both}.hero-section{position:relative;overflow:hidden;padding:80px 24px 60px;text-align:center}.hero-section::before{content:"";position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(ellipse at 20% 50%, rgba(119, 107, 231, 0.15) 0%, transparent 50%),radial-gradient(ellipse at 80% 50%, rgba(255, 64, 129, 0.1) 0%, transparent 50%),radial-gradient(ellipse at 50% 0%, rgba(30, 200, 165, 0.08) 0%, transparent 40%);animation:heroGlow 8s ease-in-out infinite alternate;z-index:0}.hero-section>*{position:relative;z-index:1}@keyframes heroGlow{0%{transform:rotate(0deg) scale(1)}100%{transform:rotate(3deg) scale(1.05)}}.hero-title{font-family:"Audiowide","Roboto","Helvetica","Arial",sans-serif !important;font-size:clamp(48px,8vw,80px);font-weight:600;background:linear-gradient(82deg, #776be7 0%, #EA80FC 40%, #ff4081 100%);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text;letter-spacing:.15em;margin-bottom:8px}.hero-subtitle{font-family:"Audiowide","Roboto","Helvetica","Arial",sans-serif !important;font-size:clamp(16px,3vw,24px);color:var(--mud-palette-text-secondary);letter-spacing:.2em;margin-bottom:24px}.hero-description{font-size:clamp(14px,2vw,18px);color:var(--mud-palette-text-secondary);max-width:640px;margin:0 auto 32px;line-height:1.6}.hero-cta-primary{border-radius:8px !important;text-transform:none !important;font-weight:500 !important;letter-spacing:.02em !important;padding:10px 28px !important}.hero-cta-secondary{border-radius:8px !important;text-transform:none !important;font-weight:500 !important;letter-spacing:.02em !important;padding:10px 28px !important}.stats-bar{display:flex;justify-content:center;gap:48px;flex-wrap:wrap;padding:32px 16px}.stat-item{text-align:center}.stat-item .stat-value{font-size:32px;font-weight:700;background:var(--atc-gradient-primary);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text}.stat-item .stat-label{font-size:13px;color:var(--mud-palette-text-secondary);text-transform:uppercase;letter-spacing:.1em;margin-top:4px}.featured-repos-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:24px;padding:0 16px}.repo-card{border-radius:12px;padding:24px;background-color:var(--mud-palette-surface);position:relative;overflow:hidden;transition:box-shadow .3s ease,transform .2s ease;cursor:pointer}.repo-card::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:var(--atc-gradient-primary);opacity:.7;transition:opacity .3s ease}.repo-card:hover{box-shadow:var(--atc-glow-purple);transform:translateY(-3px)}.repo-card:hover::before{opacity:1}.repo-card .repo-card-name{font-size:18px;font-weight:600;color:var(--mud-palette-text-primary);margin-bottom:8px}.repo-card .repo-card-description{font-size:14px;color:var(--mud-palette-text-secondary);margin-bottom:16px;line-height:1.5}.repo-card .repo-card-meta{display:flex;align-items:center;flex-wrap:wrap;gap:4px;margin-top:auto}.repo-card-azure::before{background:var(--atc-gradient-teal)}.repo-card-rest::before{background:var(--atc-gradient-pink)}.repo-card-tools::before{background:var(--atc-gradient-orange)}.repo-card-core::before{background:var(--atc-gradient-purple)}.repo-card-ai::before{background:var(--atc-gradient-blue)}.repo-card-iot::before{background:var(--atc-gradient-green)}.repo-card-industrial::before{background:var(--atc-gradient-amber)}.repo-card-devtools::before{background:var(--atc-gradient-cyan)}.section-title{font-size:28px;font-weight:600;margin-bottom:8px;color:var(--mud-palette-text-primary)}.section-subtitle{font-size:15px;color:var(--mud-palette-text-secondary);margin-bottom:32px}.feature-card{height:100%;border-radius:12px;padding:28px;background-color:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default);transition:box-shadow .3s ease,transform .2s ease,border-color .3s ease}.feature-card:hover{border-color:rgba(119,107,231,.3);box-shadow:var(--atc-glow-purple);transform:translateY(-2px)}.feature-card .feature-icon{font-size:32px;margin-bottom:16px;color:#776be7}.feature-card .feature-title{font-size:16px;font-weight:600;margin-bottom:8px;color:var(--mud-palette-text-primary)}.feature-card .feature-description{font-size:14px;color:var(--mud-palette-text-secondary);line-height:1.5}.focus-card{display:block;border-radius:12px;padding:28px;background-color:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default);transition:border-color .3s ease,box-shadow .3s ease,transform .2s ease;height:100%;color:inherit}.focus-card:hover{border-color:var(--mud-palette-primary);box-shadow:0 4px 20px rgba(119,107,231,.15);transform:translateY(-2px)}.focus-card .feature-icon{font-size:32px;margin-bottom:12px}.focus-card .feature-title{font-size:16px;font-weight:600;margin-bottom:8px}.focus-card .feature-description{font-size:14px;color:var(--mud-palette-text-secondary);line-height:1.5}.cli-hero-stats{text-align:center;padding:16px 0}.cli-hero-count{font-size:56px;font-weight:800;background:var(--atc-gradient-cyan);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text;line-height:1;letter-spacing:-0.02em}.cli-hero-label{font-size:15px;color:var(--mud-palette-text-secondary);text-transform:uppercase;letter-spacing:.1em;margin-top:8px}.cli-tools-grid{display:grid;grid-template-columns:repeat(2, 1fr);gap:24px}@media(max-width: 960px){.cli-tools-grid{grid-template-columns:1fr}}.cli-tool-card{background-color:var(--mud-palette-surface);border-radius:12px;padding:24px;position:relative;overflow:hidden;transition:box-shadow .3s ease,transform .2s ease;display:flex;flex-direction:column}.cli-tool-card::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:var(--atc-gradient-cyan);opacity:.7;transition:opacity .3s ease}.cli-tool-card:hover{transform:translateY(-3px);box-shadow:0 8px 30px rgba(0,188,212,.12)}.cli-tool-card:hover::before{opacity:1}.cli-tool-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.cli-tool-name{font-size:17px;font-weight:700;color:var(--mud-palette-text-primary);letter-spacing:-0.01em;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cli-version-chip{flex-shrink:0;background:rgba(0,188,212,.12) !important;color:#4dd0e1 !important;font-size:12px !important;font-weight:600 !important;font-family:"Cascadia Code","Fira Code",monospace !important}.cli-tool-description{color:var(--mud-palette-text-secondary);line-height:1.5;margin-bottom:16px;flex:1;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.cli-install-block{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.25);border:1px solid hsla(0,0%,100%,.06);border-radius:8px;padding:10px 12px;margin-bottom:16px;font-family:"Cascadia Code","Fira Code","Consolas",monospace;font-size:13px;transition:border-color .2s ease}.cli-install-block:hover{border-color:rgba(0,188,212,.3)}.cli-install-block .cli-prompt{color:#4dd0e1;font-weight:700;flex-shrink:0;user-select:none}.cli-install-block code{flex:1;color:var(--mud-palette-text-primary);word-break:break-all;line-height:1.4}.cli-install-block .cli-copy-btn{flex-shrink:0;opacity:.4;transition:opacity .2s ease}.cli-install-block .cli-copy-btn:hover{opacity:1}.cli-tags{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:16px}.cli-tag{font-size:11px !important;height:22px !important}.cli-tool-footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:12px;border-top:1px solid hsla(0,0%,100%,.06)}.cli-downloads{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--mud-palette-text-secondary)}.cli-tool-links{display:flex;align-items:center;gap:2px}.docs-page{max-width:100%}.docs-page>.atc-container>.atc-typography-h4{margin-top:52px}.docs-page-header .atc-typography-h3{font-size:40px;margin-bottom:16px}.docs-page-header .atc-typography-h4{margin-top:0px}.docs-page-header{margin-bottom:50px}.docs-page-content-navigation-drawer{pointer-events:none}.docs-page-content-navigation-drawer .mud-drawer-content,.docs-page-content-navigation-drawer .mud-nav-link,.docs-page-content-navigation-drawer .title{pointer-events:auto}.docs-page-content-navigation-drawer .mud-drawer-content{overflow:visible}.docs-page-content-navigation-drawer .title{font-weight:500}.docs-page-content-navigation-drawer .navigation-level-0 .mud-nav-link-text{padding-inline-start:0}.docs-page-content-navigation-drawer .navigation-level-1 .mud-nav-link-text{padding-inline-start:14px}.docs-page-section{margin-bottom:50px}.docs-page-section p{margin-bottom:16px}.docs-page-section p:last-child{margin-bottom:0px}.docs-page-section ul li{margin-left:16px;list-style-type:disc}.docs-code{display:inline-block;padding:0 5px;direction:ltr;font-size:.85em;font-weight:900;font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;line-height:1.4;border-radius:2px;-webkit-font-smoothing:subpixel-antialiased}.docs-code.docs-code-primary{color:var(--mud-palette-primary);background-color:var(--mud-palette-primary-hover)}.docs-code.docs-code-secondary{color:var(--mud-palette-secondary);background-color:var(--mud-palette-secondary-hover)}.docs-code.docs-code-tertiary{color:var(--mud-palette-tertiary);background-color:var(--mud-palette-tertiary-hover)}.docs-frame{height:100%;width:100%;position:relative;height:400px}.docs-frame .docs-frame-absolute{position:absolute;bottom:0px;left:0px;top:0px;right:auto;width:100%;height:100%}.docs-frame .docs-frame-absolute .docs-frame-inner-wrapper{display:flex;height:100%;overflow:auto;flex-direction:column}.docs-contents{display:none}.docs-page-section[api-link-section]{margin:0px}.docs-page-section[api-link-section] .docs-section-header{margin:0px}.docs-page-section[api-link-section] .docs-section-header .atc-typography{margin:0px}.docs-page-apilinks ul li a:hover{text-decoration:none !important}.docs-page-apilinks ul li a .docs-code{transition:background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms}.docs-page-apilinks ul li a .docs-code:hover{text-decoration:none !important}.docs-section-header{margin-bottom:20px}.docs-section-header .atc-typography-h4{font-size:30px;margin:40px 0 16px}.docs-section-content{padding:24px;border-radius:12px;margin-bottom:40px;background-color:var(--mud-palette-surface);margin:auto;display:flex;outline:0;position:relative;justify-content:center;border-left:3px solid rgba(0,0,0,0);border-image:linear-gradient(180deg, #776be7 0%, #ff4081 100%) 1;border-image-slice:1}.docs-section-content .docs-section-content{border-left:none;border-image:none}.docs-section-content{backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);transition:box-shadow .3s ease,transform .2s ease}.docs-section-content .docs-section-content-inner{margin-left:auto;margin-right:auto;display:block;outline:0;position:relative;justify-content:center}.docs-section-content.docs-display-flex>.docs-section-content-inner{display:flex;flex-grow:1;flex-wrap:wrap}.docs-section-content.docs-content-full-width>.docs-section-content-inner{width:100%}.docs-section-content.docs-section-content-darken{background-color:var(--mud-palette-background-grey)}.docs-section-content.docs-section-content-outlined{border:1px solid var(--mud-palette-lines-default)}.docs-section-content.docs-section-content-display-grid{display:grid}.docs-section-content.docs-section-auto-margin-content>.docs-section-content-inner>*:not(.atc-grid){margin:8px}.docs-section-content.docs-section-flex-column>.docs-section-content-inner{flex-direction:column;align-items:center}.docs-section-source{height:100%}.docs-section-source .docs-content-toolbar{display:flex;z-index:25}.docs-section-source .docs-content-toolbar>.atc-tooltip-root .atc-icon-button .atc-svg-icon{font-size:1.25rem}.docs-section-source .docs-source-code{height:100%}.docs-section-source .docs-source-code.docs-show-code{display:block}.docs-section-source .docs-source-code.docs-hide-code{display:none}.docs-content-api{padding:0px;width:100%}@media(min-width: 600px){.docs-content-api-cell:before{width:30% !important;min-width:30% !important}}@media(max-width: 599px){.docs-content-api-description{flex-wrap:wrap}}.docs-content-api-description{overflow-wrap:anywhere;white-space:pre-line}.docs-content-outlined{border:1px solid var(--mud-palette-lines-default)}.docs-section-wireframe{height:calc(80vh - 124px);height:calc(80dvh - 124px)}.docs-section-black{background-color:var(--mud-palette-background)}.docs-iframe{height:400px;width:100%;margin:0px !important}.docs-icon-container{margin-top:20px;display:flex;flex-wrap:wrap}.docs-icon-container .atc-divider{margin-bottom:20px}.docs-icon-panel{height:120px;width:90px;padding:20px 10px;display:inline-block;text-align:center}.docs-icon-panel .atc-typography{margin-top:10px;width:100%;text-overflow:ellipsis;overflow:hidden}.docs-icon-panel-inner{display:flex;flex-direction:column;align-items:center;justify-content:center}.docs-icon-panel-inner .atc-svg-icon{font-size:40px}.nuget-pill-button{display:inline-flex;align-items:center;padding:3px 3px 3px 14px;background:var(--atc-gradient-purple);color:#fff;font-weight:500;font-size:12px;border-radius:9999px;border:none;cursor:pointer;text-decoration:none;transition:box-shadow .3s ease;gap:8px;white-space:nowrap}.nuget-pill-button:hover{box-shadow:var(--atc-glow-purple);text-decoration:none;color:#fff}.nuget-pill-button:hover .pill-arrow-circle .pill-arrow{transform:rotate(45deg)}.nuget-pill-button .pill-arrow-circle{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#fff;border-radius:50%}.nuget-pill-button .pill-arrow-circle .pill-arrow{color:#776be7;font-size:16px;transition:transform .3s ease}.repo-detail-tabs .mud-tabs-toolbar{border-bottom:1px solid rgba(119,107,231,.15);background:rgba(0,0,0,0)}.repo-detail-tabs .mud-tab{text-transform:none !important;font-weight:500 !important;font-size:14px !important;letter-spacing:.02em !important;min-width:auto !important;padding:12px 20px !important}.repo-detail-tabs .mud-tab-active{color:#776be7 !important}.repo-detail-tabs .mud-tabs-toolbar-indicator{background:var(--atc-gradient-primary) !important;height:3px;border-radius:3px 3px 0 0}.repo-sidebar-card{background-color:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default)}.repo-sidebar-card .mud-card-content{padding:20px}.sidebar-stat-row{display:flex;align-items:center;justify-content:space-between;padding:4px 0;font-size:14px;color:var(--mud-palette-text-secondary)}.sidebar-stat-link{text-decoration:none;color:var(--mud-palette-text-secondary);transition:color .2s ease}.sidebar-stat-link:hover{color:var(--mud-palette-text-primary);text-decoration:none}.sidebar-stat-value{display:flex;align-items:center;font-weight:600;color:var(--mud-palette-text-primary)}.framework-item{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--mud-palette-text-secondary)}.feature-dot{width:6px;height:6px;border-radius:50%;background:#776be7;flex-shrink:0;margin-top:7px}.copyable-snippet{border-radius:8px;overflow:hidden;border:1px solid rgba(119,107,231,.15)}.copyable-snippet .copyable-snippet-header{display:flex;align-items:center;justify-content:space-between;padding:4px 4px 4px 12px;font-size:12px;font-weight:500;color:var(--mud-palette-text-secondary);border-bottom:1px solid rgba(119,107,231,.1)}.copyable-snippet .copyable-snippet-header .copyable-snippet-btn{opacity:.4;transition:opacity .2s ease}.copyable-snippet .copyable-snippet-header .copyable-snippet-btn:hover{opacity:1}.copyable-snippet .copyable-snippet-body{padding:10px 12px;font-family:"Cascadia Code","Fira Code","Consolas",monospace;font-size:13px;line-height:1.4;overflow-x:auto}.copyable-snippet .copyable-snippet-body code{color:var(--mud-palette-text-primary);white-space:nowrap}.mud-theme-dark .copyable-snippet .copyable-snippet-body{background:rgba(0,0,0,.25)}:not(.mud-theme-dark) .copyable-snippet .copyable-snippet-body{background:rgba(0,0,0,.03)} +.atc-loader{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;min-height:100dvh;font-family:cera pro;font-size:24px;text-align:center;color:#776be7;background-color:#0f0f1a}.atc-loader img{height:auto}.atc-loader-logo{animation:atc-pulse 2s ease-in-out infinite}.atc-loader-title{font-family:"Audiowide","Roboto",sans-serif;font-size:28px;background:linear-gradient(82deg, #673AB7 0%, #EA80FC 100%);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);margin-top:12px;letter-spacing:2px}.atc-loader-subtitle{font-family:"Roboto",sans-serif;font-size:14px;color:rgba(200,198,216,.6);margin-top:4px;letter-spacing:1px}.atc-loader-bar{width:220px;height:3px;background:rgba(119,107,231,.15);border-radius:3px;overflow:hidden;margin-top:24px}.atc-loader-bar-fill{height:100%;width:40%;border-radius:3px;background:linear-gradient(90deg, #673AB7, #EA80FC);animation:atc-slide 1.2s ease-in-out infinite}@keyframes atc-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.6;transform:scale(0.97)}}@keyframes atc-slide{0%{transform:translateX(-100%)}100%{transform:translateX(450%)}}.app-loading-text{font-family:"Audiowide","Roboto","Helvetica","Arial","sans-serif" !important;font-size:32px;background:linear-gradient(82deg, #673AB7 0%, #EA80FC 100%);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0)}.atc-h1-gradient{font-family:"Audiowide","Roboto","Helvetica","Arial","sans-serif" !important;font-size:64px;font-weight:600;font-style:italic;background:linear-gradient(82deg, #673AB7 0%, #EA80FC 100%);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0)}.atc-appbar-brand-text{font-family:"Audiowide","Roboto","Helvetica","Arial","sans-serif" !important}.full-page-height{min-height:100vh;min-height:100dvh}.atc-brand{margin:32px 0 0;padding:0px}.atc-brand:hover{cursor:pointer;color:var(--mud-palette-primary);transition:color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms}atc-appbar-band{overflow:hidden}@media(max-width: 600px){.atc-appbar-band{background-position:14px 0px}}.atc-appbar-brand-text{letter-spacing:.5rem;font-weight:300;margin-left:12px;font-size:24px;user-select:none;background:linear-gradient(82deg, #776be7 0%, #EA80FC 50%, #ff4081 100%);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text}@media(max-width: 400px){.atc-appbar-brand-text{letter-spacing:.25rem}}.atc-navmenu{overflow-x:hidden;overflow-y:scroll;margin-bottom:12px}.docs-nav-fader{display:block;position:sticky;top:0;height:40px;background-image:linear-gradient(to top, rgba(255, 255, 255, 0), var(--mud-palette-drawer-background));z-index:100}.docs-nav-filler{display:flex;flex-grow:1}.atc-container+.atc-container{margin-bottom:128px}.atc-main-content{padding-top:86px;padding-bottom:calc(86px + env(safe-area-inset-bottom, 0px));padding-left:env(safe-area-inset-left, 0px);padding-right:env(safe-area-inset-right, 0px)}@media(min-width: 960px){.atc-main-content{padding-top:128px;padding-bottom:64px;padding-left:12px}}.docs-list{list-style:inside}.docs-expand-wrapper{color:var(--mud-palette-text-primary);background-color:var(--mud-palette-surface)}.docs-expand-wrapper .docs-default-theme .mud-expand-panel{color:inherit;background-color:inherit;border:none !important}.docs-expand-wrapper .docs-default-theme .mud-expand-panel .mud-expand-panel-text{color:var(--mud-palette-primary);font-weight:500;font-size:1rem}.mud-table-toolbar{margin-bottom:50px}.mud-simple-table>div>table thead>tr>th{border:none}.mud-simple-table>div>table tbody>tr>td{border:none !important}.mud-simple-table.mud-table-bordered>div>table thead>tr>th{border:1px groove var(--mud-palette-table-lines)}.mud-simple-table.mud-table-bordered>div>table tbody>tr>td{border:1px groove var(--mud-palette-table-lines) !important}.atc-simple-table-clean tr>td{border:none;margin:0px !important;padding:4px 0px !important;border:none !important}.atc-simple-table-clean.table-no-padding>tr>td{padding:0px !important}.atc-simple-table-clean-bottom-line>tr>td{border:none;margin:0px !important;padding:4px 0px !important;border:none !important;border-bottom:1px groove var(--mud-palette-table-lines) !important}.atc-simple-table-clean-bottom-line>tr:last-child>td{border:none;margin:0px !important;padding:4px 0px !important;border:none !important}#mudads{margin-bottom:50px;min-height:100px}#carbonads *{margin:initial;padding:initial}#carbonads{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",Helvetica,Arial,sans-serif}#carbonads{display:flex;width:100%;max-width:400px;background-color:var(--mud-palette-surface);box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:var(--mud-default-borderradius);z-index:100;animation:mud-animation-fadein ease .15s}#carbonads a{color:inherit;text-decoration:none}#carbonads a:hover{color:inherit}#carbonads span{position:relative;display:block;overflow:hidden}#carbonads .carbon-wrap{display:flex}#carbonads .carbon-img{display:block;margin:0;line-height:1}#carbonads .carbon-img img{display:block;border-bottom-left-radius:var(--mud-default-borderradius);border-top-left-radius:var(--mud-default-borderradius)}#carbonads .carbon-text{font-size:13px;padding:10px;margin-bottom:16px;line-height:1.5;text-align:left}#carbonads .carbon-poweredby{display:block;padding:6px 8px;text-align:center;text-transform:uppercase;letter-spacing:.5px;font-weight:600;font-size:8px;line-height:1;border-top-left-radius:3px;position:absolute;bottom:0;right:0}.markdown h1,.markdown h2,.markdown h3,.markdown h4,.markdown h5,.markdown h6{margin-top:16px}.markdown a{color:var(--mud-palette-primary)}.markdown p{margin-top:0;margin-bottom:16px}.markdown ul,.markdown ol{padding-left:2em;margin-top:0;margin-bottom:16px}.markdown ul li{list-style-type:disc}.markdown pre{background-color:var(--mud-palette-dark);border-radius:12px}.markdown pre pre{border-radius:0px}.markdown pre>code{display:block;height:100%;margin:8px 0px;padding:16px;color:#ededed;direction:ltr;overflow:auto;font-size:85%;font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;-webkit-font-smoothing:subpixel-antialiased}.markdown p code{margin:0;padding:.2em .4em;border-radius:6px;color:#ededed;background-color:var(--mud-palette-dark);font-size:85%}.markdown table{border-collapse:collapse;border:1px groove var(--mud-palette-table-lines) !important}.markdown table tr th{padding:8px;border:1px groove var(--mud-palette-table-lines)}.markdown table tr td{padding:8px;border:1px groove var(--mud-palette-table-lines)}.atc-codeblock{height:100%;background-color:var(--mud-palette-black);border-radius:12px;padding:16px;overflow:auto;border-left:3px solid #776be7}.atc-codeblock pre{height:100%;color:#ededed;padding:0px;font-size:1em;font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;-webkit-font-smoothing:subpixel-antialiased;direction:ltr}.atc-codeblock .html+.csharp{margin-top:23px}.atc-codeblock .html .htmlTagDelimiter{color:#979797}.atc-codeblock .html .htmlElementName{color:#988ef1}.atc-codeblock .html .htmlAttributeName{color:#27b5b5}.atc-codeblock .html .htmlOperator,.atc-codeblock .html .quot{color:#c8c8c8}.atc-codeblock .html .htmlAttributeValue{color:#ededed}.atc-codeblock .html .htmlLink{color:#61afef;text-decoration:underline}.atc-codeblock .html .enum{color:#b4eb8f;background-color:hsla(0,0%,100%,.15)}.atc-codeblock .html .enumValue,.atc-codeblock .html .sharpVariable{color:#ededed;background-color:hsla(0,0%,100%,.15)}.atc-codeblock .html .keyword{color:#61afef;background-color:hsla(0,0%,100%,.15)}.atc-codeblock .html .atSign{color:#000;background-color:#d2d295}.atc-codeblock .html .comment{color:#57a64a}.atc-codeblock .csharp .atSign{color:#000;background-color:#d2d295}.atc-codeblock .csharp .keyword{color:#569cd6}.atc-codeblock .csharp .string{color:#d69d85}.atc-codeblock .csharp .function{color:#dcdcaa}.atc-codeblock .csharp .class{color:#4ec9b0}.atc-codeblock .csharp .localVar{color:#9cdcfe}.atc-codeblock .csharp .interface{color:#b0d7a3}.atc-codeblock .csharp .number{color:#b0d7a3}.atc-codeblock .csharp .enum{color:#b4eb8f}.atc-codeblock .csharp .comment{color:#57a64a}:root{--atc-gradient-primary: linear-gradient(135deg, #776be7 0%, #ff4081 100%);--atc-gradient-purple: linear-gradient(135deg, #776be7 0%, #9b59b6 100%);--atc-gradient-teal: linear-gradient(135deg, #1ec8a5 0%, #3299ff 100%);--atc-gradient-pink: linear-gradient(135deg, #ff4081 0%, #e84393 100%);--atc-gradient-orange: linear-gradient(135deg, #ffa800 0%, #f64e62 100%);--atc-gradient-blue: linear-gradient(135deg, #3299ff 0%, #776be7 100%);--atc-gradient-green: linear-gradient(135deg, #00b894 0%, #1ec8a5 100%);--atc-gradient-amber: linear-gradient(135deg, #f0932b 0%, #e17055 100%);--atc-gradient-cyan: linear-gradient(135deg, #00cec9 0%, #0984e3 100%);--atc-glow-purple: 0 0 20px rgba(119, 107, 231, 0.15);--atc-glow-pink: 0 0 20px rgba(255, 64, 129, 0.15)}.atc-appbar-frosted{backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border-bottom:1px solid rgba(119,107,231,.15)}.atc-nav-button{text-transform:none !important;font-weight:400 !important;font-size:14px !important;letter-spacing:.02em !important;opacity:.85;transition:opacity .2s ease}.atc-nav-button:hover{opacity:1;background-color:rgba(119,107,231,.08) !important}.accent-card,.accent-card-blue,.accent-card-orange,.accent-card-pink,.accent-card-teal,.accent-card-purple{border-radius:12px;background-color:var(--mud-palette-surface);border-left:3px solid rgba(0,0,0,0);border-image:var(--atc-gradient-primary) 1;border-image-slice:1;transition:box-shadow .3s ease,transform .2s ease}.accent-card:hover,.accent-card-blue:hover,.accent-card-orange:hover,.accent-card-pink:hover,.accent-card-teal:hover,.accent-card-purple:hover{box-shadow:var(--atc-glow-purple);transform:translateY(-2px)}.accent-card-purple{border-image:var(--atc-gradient-purple) 1}.accent-card-purple:hover{box-shadow:0 0 24px rgba(119,107,231,.2)}.accent-card-teal{border-image:var(--atc-gradient-teal) 1}.accent-card-teal:hover{box-shadow:0 0 24px rgba(30,200,165,.2)}.accent-card-pink{border-image:var(--atc-gradient-pink) 1}.accent-card-pink:hover{box-shadow:0 0 24px rgba(255,64,129,.2)}.accent-card-orange{border-image:var(--atc-gradient-orange) 1}.accent-card-orange:hover{box-shadow:0 0 24px rgba(255,168,0,.2)}.accent-card-blue{border-image:var(--atc-gradient-blue) 1}.accent-card-blue:hover{box-shadow:0 0 24px rgba(50,153,255,.2)}.accent-top-card{border-radius:12px;background-color:var(--mud-palette-surface);position:relative;overflow:hidden;transition:box-shadow .3s ease,transform .2s ease}.accent-top-card::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:var(--atc-gradient-primary)}.accent-top-card:hover{box-shadow:var(--atc-glow-purple);transform:translateY(-2px)}.glass-panel{backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border-radius:16px}.mud-theme-dark .glass-panel{background:rgba(22,22,42,.6);border:1px solid rgba(119,107,231,.1)}:not(.mud-theme-dark) .glass-panel{background:hsla(0,0%,100%,.7);border:1px solid rgba(108,92,231,.1)}.glass-panel-light{background:hsla(0,0%,100%,.7);backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(108,92,231,.1);border-radius:16px}.gradient-text{background:var(--atc-gradient-primary);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text}.gradient-divider{height:2px;background:var(--atc-gradient-primary);border:none;border-radius:1px;opacity:.5}.docs-page-content-navigation-drawer .mud-nav-link.active{border-left-color:#776be7}.docs-page-content-navigation-drawer .mud-nav-link.active::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--atc-gradient-primary);border-radius:0 2px 2px 0}.mud-drawer{border-right:1px solid rgba(119,107,231,.08) !important;overflow-x:hidden !important}.mud-paper{border-radius:12px !important}.mud-nav-link{border-radius:8px !important;margin:2px 8px}.mud-nav-link:hover{background-color:rgba(119,107,231,.08) !important}.mud-nav-link.active{background-color:rgba(119,107,231,.12) !important;color:#776be7 !important}:not(.mud-theme-dark) .mud-nav-link:hover{background-color:rgba(119,107,231,.1) !important}:not(.mud-theme-dark) .mud-nav-link.active{background-color:rgba(119,107,231,.15) !important}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:rgba(0,0,0,0)}.mud-theme-dark ::-webkit-scrollbar-thumb,.mud-theme-dark::-webkit-scrollbar-thumb{background:rgba(119,107,231,.3);border-radius:4px}.mud-theme-dark ::-webkit-scrollbar-thumb:hover,.mud-theme-dark::-webkit-scrollbar-thumb:hover{background:rgba(119,107,231,.5)}:not(.mud-theme-dark) ::-webkit-scrollbar-thumb,:not(.mud-theme-dark)::-webkit-scrollbar-thumb{background:rgba(119,107,231,.2);border-radius:4px}:not(.mud-theme-dark) ::-webkit-scrollbar-thumb:hover,:not(.mud-theme-dark)::-webkit-scrollbar-thumb:hover{background:rgba(119,107,231,.4)}.mermaid,pre.mermaid{background:rgba(0,0,0,0) !important;text-align:center;padding:16px 0}.mermaid svg,pre.mermaid svg{max-width:100%;height:auto}.mermaid .node rect,.mermaid .node circle,.mermaid .node polygon,pre.mermaid .node rect,pre.mermaid .node circle,pre.mermaid .node polygon{stroke:#776be7 !important;stroke-width:1.5px !important}.mermaid .edgePath .path,pre.mermaid .edgePath .path{stroke:#776be7 !important;stroke-width:1.5px !important}.mermaid .arrowheadPath,pre.mermaid .arrowheadPath{fill:#776be7 !important}.mud-theme-dark .mermaid .nodeLabel,.mud-theme-dark pre.mermaid .nodeLabel{color:#1a1a2e !important}.mud-theme-dark .mermaid .edgeLabel,.mud-theme-dark pre.mermaid .edgeLabel{color:#c8c6d8 !important;background-color:rgba(0,0,0,0) !important}.mud-theme-dark .mermaid .edgeLabel rect,.mud-theme-dark pre.mermaid .edgeLabel rect{fill:#16162a !important;opacity:.9}.mud-theme-dark .mermaid text,.mud-theme-dark pre.mermaid text{fill:#c8c6d8 !important}.mud-theme-dark .mermaid .cluster rect,.mud-theme-dark pre.mermaid .cluster rect{fill:#16162a !important;stroke:#2a2a4a !important}:not(.mud-theme-dark) .mermaid .nodeLabel,:not(.mud-theme-dark) pre.mermaid .nodeLabel{color:#2d2b42 !important}:not(.mud-theme-dark) .mermaid .edgeLabel,:not(.mud-theme-dark) pre.mermaid .edgeLabel{color:#4a4568 !important;background-color:rgba(0,0,0,0) !important}:not(.mud-theme-dark) .mermaid .edgeLabel rect,:not(.mud-theme-dark) pre.mermaid .edgeLabel rect{fill:#f8f7fc !important;opacity:.9}:not(.mud-theme-dark) .mermaid text,:not(.mud-theme-dark) pre.mermaid text{fill:#4a4568 !important}:not(.mud-theme-dark) .mermaid .cluster rect,:not(.mud-theme-dark) pre.mermaid .cluster rect{fill:#f0eff5 !important;stroke:#ddd9e9 !important}.atc-navmenu::-webkit-scrollbar{width:6px}.atc-navmenu::-webkit-scrollbar-thumb{background:rgba(119,107,231,.15);border-radius:3px}.atc-navmenu::-webkit-scrollbar-thumb:hover{background:rgba(119,107,231,.4)}@keyframes fadeInUp{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}.fade-in-up{animation:fadeInUp .5s ease-out both}.hero-section{position:relative;overflow:hidden;padding:80px 24px 60px;text-align:center}.hero-section::before{content:"";position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(ellipse at 20% 50%, rgba(119, 107, 231, 0.15) 0%, transparent 50%),radial-gradient(ellipse at 80% 50%, rgba(255, 64, 129, 0.1) 0%, transparent 50%),radial-gradient(ellipse at 50% 0%, rgba(30, 200, 165, 0.08) 0%, transparent 40%);animation:heroGlow 8s ease-in-out infinite alternate;z-index:0}.hero-section>*{position:relative;z-index:1}@keyframes heroGlow{0%{transform:rotate(0deg) scale(1)}100%{transform:rotate(3deg) scale(1.05)}}.hero-title{font-family:"Audiowide","Roboto","Helvetica","Arial",sans-serif !important;font-size:clamp(48px,8vw,80px);font-weight:600;background:linear-gradient(82deg, #776be7 0%, #EA80FC 40%, #ff4081 100%);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text;letter-spacing:.15em;margin-bottom:8px}.hero-subtitle{font-family:"Audiowide","Roboto","Helvetica","Arial",sans-serif !important;font-size:clamp(16px,3vw,24px);color:var(--mud-palette-text-secondary);letter-spacing:.2em;margin-bottom:24px}.hero-description{font-size:clamp(14px,2vw,18px);color:var(--mud-palette-text-secondary);max-width:640px;margin:0 auto 32px;line-height:1.6}.hero-cta-primary{border-radius:8px !important;text-transform:none !important;font-weight:500 !important;letter-spacing:.02em !important;padding:10px 28px !important}.hero-cta-secondary{border-radius:8px !important;text-transform:none !important;font-weight:500 !important;letter-spacing:.02em !important;padding:10px 28px !important}.stats-bar{display:flex;justify-content:center;gap:48px;flex-wrap:wrap;padding:32px 16px}.stat-item{text-align:center}.stat-item .stat-value{font-size:32px;font-weight:700;background:var(--atc-gradient-primary);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text}.stat-item .stat-label{font-size:13px;color:var(--mud-palette-text-secondary);text-transform:uppercase;letter-spacing:.1em;margin-top:4px}.featured-repos-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:24px;padding:0 16px}.repo-card{border-radius:12px;padding:24px;background-color:var(--mud-palette-surface);position:relative;overflow:hidden;transition:box-shadow .3s ease,transform .2s ease;cursor:pointer}.repo-card::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:var(--atc-gradient-primary);opacity:.7;transition:opacity .3s ease}.repo-card:hover{box-shadow:var(--atc-glow-purple);transform:translateY(-3px)}.repo-card:hover::before{opacity:1}.repo-card .repo-card-name{font-size:18px;font-weight:600;color:var(--mud-palette-text-primary);margin-bottom:8px}.repo-card .repo-card-description{font-size:14px;color:var(--mud-palette-text-secondary);margin-bottom:16px;line-height:1.5}.repo-card .repo-card-meta{display:flex;align-items:center;flex-wrap:wrap;gap:4px;margin-top:auto}.repo-card-azure::before{background:var(--atc-gradient-teal)}.repo-card-rest::before{background:var(--atc-gradient-pink)}.repo-card-tools::before{background:var(--atc-gradient-orange)}.repo-card-core::before{background:var(--atc-gradient-purple)}.repo-card-ai::before{background:var(--atc-gradient-blue)}.repo-card-iot::before{background:var(--atc-gradient-green)}.repo-card-industrial::before{background:var(--atc-gradient-amber)}.repo-card-devtools::before{background:var(--atc-gradient-cyan)}.section-title{font-size:28px;font-weight:600;margin-bottom:8px;color:var(--mud-palette-text-primary)}.section-subtitle{font-size:15px;color:var(--mud-palette-text-secondary);margin-bottom:32px}.feature-card{height:100%;border-radius:12px;padding:28px;background-color:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default);transition:box-shadow .3s ease,transform .2s ease,border-color .3s ease}.feature-card:hover{border-color:rgba(119,107,231,.3);box-shadow:var(--atc-glow-purple);transform:translateY(-2px)}.feature-card .feature-icon{font-size:32px;margin-bottom:16px;color:#776be7}.feature-card .feature-title{font-size:16px;font-weight:600;margin-bottom:8px;color:var(--mud-palette-text-primary)}.feature-card .feature-description{font-size:14px;color:var(--mud-palette-text-secondary);line-height:1.5}.focus-card{display:block;border-radius:12px;padding:28px;background-color:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default);transition:border-color .3s ease,box-shadow .3s ease,transform .2s ease;height:100%;color:inherit}.focus-card:hover{border-color:var(--mud-palette-primary);box-shadow:0 4px 20px rgba(119,107,231,.15);transform:translateY(-2px)}.focus-card .feature-icon{font-size:32px;margin-bottom:12px}.focus-card .feature-title{font-size:16px;font-weight:600;margin-bottom:8px}.focus-card .feature-description{font-size:14px;color:var(--mud-palette-text-secondary);line-height:1.5}.cli-hero-stats{text-align:center;padding:16px 0}.cli-hero-count{font-size:56px;font-weight:800;background:var(--atc-gradient-cyan);-webkit-background-clip:text;-webkit-text-fill-color:rgba(0,0,0,0);background-clip:text;line-height:1;letter-spacing:-0.02em}.cli-hero-label{font-size:15px;color:var(--mud-palette-text-secondary);text-transform:uppercase;letter-spacing:.1em;margin-top:8px}.cli-tools-grid{display:grid;grid-template-columns:repeat(2, 1fr);gap:24px}@media(max-width: 960px){.cli-tools-grid{grid-template-columns:1fr}}.cli-tool-card{background-color:var(--mud-palette-surface);border-radius:12px;padding:24px;position:relative;overflow:hidden;transition:box-shadow .3s ease,transform .2s ease;display:flex;flex-direction:column}.cli-tool-card::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:var(--atc-gradient-cyan);opacity:.7;transition:opacity .3s ease}.cli-tool-card:hover{transform:translateY(-3px);box-shadow:0 8px 30px rgba(0,188,212,.12)}.cli-tool-card:hover::before{opacity:1}.cli-tool-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.cli-tool-name{font-size:17px;font-weight:700;color:var(--mud-palette-text-primary);letter-spacing:-0.01em;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cli-version-chip{flex-shrink:0;background:rgba(0,188,212,.12) !important;color:#4dd0e1 !important;font-size:12px !important;font-weight:600 !important;font-family:"Cascadia Code","Fira Code",monospace !important}.cli-tool-description{color:var(--mud-palette-text-secondary);line-height:1.5;margin-bottom:16px;flex:1;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.cli-install-block{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.25);border:1px solid hsla(0,0%,100%,.06);border-radius:8px;padding:10px 12px;margin-bottom:16px;font-family:"Cascadia Code","Fira Code","Consolas",monospace;font-size:13px;transition:border-color .2s ease}.cli-install-block:hover{border-color:rgba(0,188,212,.3)}.cli-install-block .cli-prompt{color:#4dd0e1;font-weight:700;flex-shrink:0;user-select:none}.cli-install-block code{flex:1;color:var(--mud-palette-text-primary);word-break:break-all;line-height:1.4}.cli-install-block .cli-copy-btn{flex-shrink:0;opacity:.4;transition:opacity .2s ease}.cli-install-block .cli-copy-btn:hover{opacity:1}.cli-tags{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:16px}.cli-tag{font-size:11px !important;height:22px !important}.cli-tool-footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:12px;border-top:1px solid hsla(0,0%,100%,.06)}.cli-downloads{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--mud-palette-text-secondary)}.cli-tool-links{display:flex;align-items:center;gap:2px}.atc-kpi-strip .atc-kpi-tile{background:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default);border-radius:8px;padding:12px 16px;min-width:120px}.atc-kpi-strip .atc-kpi-tile .atc-kpi-value{font-size:1.5rem;font-weight:600;color:var(--mud-palette-primary)}.atc-kpi-strip .atc-kpi-tile .atc-kpi-label{font-size:.75rem;color:var(--mud-palette-text-secondary);text-transform:uppercase}.atc-filter-bar{position:sticky;top:64px;z-index:10;background:var(--mud-palette-background);padding:8px 0}.atc-compliance-chip{min-width:64px;justify-content:center}.atc-row-detail{background:var(--mud-palette-surface);border-left:3px solid var(--mud-palette-primary)}.atc-link{color:var(--mud-palette-primary);text-decoration:none;font-weight:500}.atc-link:hover{text-decoration:underline}.atc-menu-section-label{padding:6px 16px 2px;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--mud-palette-text-secondary)}.atc-insights-tiles .atc-insights-tile{background:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default);border-radius:10px;padding:16px 20px;min-width:140px}.atc-insights-tiles .atc-insights-tile .atc-insights-tile-value{font-size:1.75rem;font-weight:600;color:var(--mud-palette-primary)}.atc-insights-tiles .atc-insights-tile .atc-insights-tile-label{font-size:.75rem;color:var(--mud-palette-text-secondary);text-transform:uppercase;letter-spacing:.05em}.docs-page{max-width:100%}.docs-page>.atc-container>.atc-typography-h4{margin-top:52px}.docs-page-header .atc-typography-h3{font-size:40px;margin-bottom:16px}.docs-page-header .atc-typography-h4{margin-top:0px}.docs-page-header{margin-bottom:50px}.docs-page-content-navigation-drawer{pointer-events:none}.docs-page-content-navigation-drawer .mud-drawer-content,.docs-page-content-navigation-drawer .mud-nav-link,.docs-page-content-navigation-drawer .title{pointer-events:auto}.docs-page-content-navigation-drawer .mud-drawer-content{overflow:visible}.docs-page-content-navigation-drawer .title{font-weight:500}.docs-page-content-navigation-drawer .navigation-level-0 .mud-nav-link-text{padding-inline-start:0}.docs-page-content-navigation-drawer .navigation-level-1 .mud-nav-link-text{padding-inline-start:14px}.docs-page-section{margin-bottom:50px}.docs-page-section p{margin-bottom:16px}.docs-page-section p:last-child{margin-bottom:0px}.docs-page-section ul li{margin-left:16px;list-style-type:disc}.docs-code{display:inline-block;padding:0 5px;direction:ltr;font-size:.85em;font-weight:900;font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;line-height:1.4;border-radius:2px;-webkit-font-smoothing:subpixel-antialiased}.docs-code.docs-code-primary{color:var(--mud-palette-primary);background-color:var(--mud-palette-primary-hover)}.docs-code.docs-code-secondary{color:var(--mud-palette-secondary);background-color:var(--mud-palette-secondary-hover)}.docs-code.docs-code-tertiary{color:var(--mud-palette-tertiary);background-color:var(--mud-palette-tertiary-hover)}.docs-frame{height:100%;width:100%;position:relative;height:400px}.docs-frame .docs-frame-absolute{position:absolute;bottom:0px;left:0px;top:0px;right:auto;width:100%;height:100%}.docs-frame .docs-frame-absolute .docs-frame-inner-wrapper{display:flex;height:100%;overflow:auto;flex-direction:column}.docs-contents{display:none}.docs-page-section[api-link-section]{margin:0px}.docs-page-section[api-link-section] .docs-section-header{margin:0px}.docs-page-section[api-link-section] .docs-section-header .atc-typography{margin:0px}.docs-page-apilinks ul li a:hover{text-decoration:none !important}.docs-page-apilinks ul li a .docs-code{transition:background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms}.docs-page-apilinks ul li a .docs-code:hover{text-decoration:none !important}.docs-section-header{margin-bottom:20px}.docs-section-header .atc-typography-h4{font-size:30px;margin:40px 0 16px}.docs-section-content{padding:24px;border-radius:12px;margin-bottom:40px;background-color:var(--mud-palette-surface);margin:auto;display:flex;outline:0;position:relative;justify-content:center;border-left:3px solid rgba(0,0,0,0);border-image:linear-gradient(180deg, #776be7 0%, #ff4081 100%) 1;border-image-slice:1}.docs-section-content .docs-section-content{border-left:none;border-image:none}.docs-section-content{backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);transition:box-shadow .3s ease,transform .2s ease}.docs-section-content .docs-section-content-inner{margin-left:auto;margin-right:auto;display:block;outline:0;position:relative;justify-content:center}.docs-section-content.docs-display-flex>.docs-section-content-inner{display:flex;flex-grow:1;flex-wrap:wrap}.docs-section-content.docs-content-full-width>.docs-section-content-inner{width:100%}.docs-section-content.docs-section-content-darken{background-color:var(--mud-palette-background-grey)}.docs-section-content.docs-section-content-outlined{border:1px solid var(--mud-palette-lines-default)}.docs-section-content.docs-section-content-display-grid{display:grid}.docs-section-content.docs-section-auto-margin-content>.docs-section-content-inner>*:not(.atc-grid){margin:8px}.docs-section-content.docs-section-flex-column>.docs-section-content-inner{flex-direction:column;align-items:center}.docs-section-source{height:100%}.docs-section-source .docs-content-toolbar{display:flex;z-index:25}.docs-section-source .docs-content-toolbar>.atc-tooltip-root .atc-icon-button .atc-svg-icon{font-size:1.25rem}.docs-section-source .docs-source-code{height:100%}.docs-section-source .docs-source-code.docs-show-code{display:block}.docs-section-source .docs-source-code.docs-hide-code{display:none}.docs-content-api{padding:0px;width:100%}@media(min-width: 600px){.docs-content-api-cell:before{width:30% !important;min-width:30% !important}}@media(max-width: 599px){.docs-content-api-description{flex-wrap:wrap}}.docs-content-api-description{overflow-wrap:anywhere;white-space:pre-line}.docs-content-outlined{border:1px solid var(--mud-palette-lines-default)}.docs-section-wireframe{height:calc(80vh - 124px);height:calc(80dvh - 124px)}.docs-section-black{background-color:var(--mud-palette-background)}.docs-iframe{height:400px;width:100%;margin:0px !important}.docs-icon-container{margin-top:20px;display:flex;flex-wrap:wrap}.docs-icon-container .atc-divider{margin-bottom:20px}.docs-icon-panel{height:120px;width:90px;padding:20px 10px;display:inline-block;text-align:center}.docs-icon-panel .atc-typography{margin-top:10px;width:100%;text-overflow:ellipsis;overflow:hidden}.docs-icon-panel-inner{display:flex;flex-direction:column;align-items:center;justify-content:center}.docs-icon-panel-inner .atc-svg-icon{font-size:40px}.nuget-pill-button{display:inline-flex;align-items:center;padding:3px 3px 3px 14px;background:var(--atc-gradient-purple);color:#fff;font-weight:500;font-size:12px;border-radius:9999px;border:none;cursor:pointer;text-decoration:none;transition:box-shadow .3s ease;gap:8px;white-space:nowrap}.nuget-pill-button:hover{box-shadow:var(--atc-glow-purple);text-decoration:none;color:#fff}.nuget-pill-button:hover .pill-arrow-circle .pill-arrow{transform:rotate(45deg)}.nuget-pill-button .pill-arrow-circle{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#fff;border-radius:50%}.nuget-pill-button .pill-arrow-circle .pill-arrow{color:#776be7;font-size:16px;transition:transform .3s ease}.repo-detail-tabs .mud-tabs-toolbar{border-bottom:1px solid rgba(119,107,231,.15);background:rgba(0,0,0,0)}.repo-detail-tabs .mud-tab{text-transform:none !important;font-weight:500 !important;font-size:14px !important;letter-spacing:.02em !important;min-width:auto !important;padding:12px 20px !important}.repo-detail-tabs .mud-tab-active{color:#776be7 !important}.repo-detail-tabs .mud-tabs-toolbar-indicator{background:var(--atc-gradient-primary) !important;height:3px;border-radius:3px 3px 0 0}.repo-sidebar-card{background-color:var(--mud-palette-surface);border:1px solid var(--mud-palette-lines-default)}.repo-sidebar-card .mud-card-content{padding:20px}.sidebar-stat-row{display:flex;align-items:center;justify-content:space-between;padding:4px 0;font-size:14px;color:var(--mud-palette-text-secondary)}.sidebar-stat-link{text-decoration:none;color:var(--mud-palette-text-secondary);transition:color .2s ease}.sidebar-stat-link:hover{color:var(--mud-palette-text-primary);text-decoration:none}.sidebar-stat-value{display:flex;align-items:center;font-weight:600;color:var(--mud-palette-text-primary)}.framework-item{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--mud-palette-text-secondary)}.feature-dot{width:6px;height:6px;border-radius:50%;background:#776be7;flex-shrink:0;margin-top:7px}.copyable-snippet{border-radius:8px;overflow:hidden;border:1px solid rgba(119,107,231,.15)}.copyable-snippet .copyable-snippet-header{display:flex;align-items:center;justify-content:space-between;padding:4px 4px 4px 12px;font-size:12px;font-weight:500;color:var(--mud-palette-text-secondary);border-bottom:1px solid rgba(119,107,231,.1)}.copyable-snippet .copyable-snippet-header .copyable-snippet-btn{opacity:.4;transition:opacity .2s ease}.copyable-snippet .copyable-snippet-header .copyable-snippet-btn:hover{opacity:1}.copyable-snippet .copyable-snippet-body{padding:10px 12px;font-family:"Cascadia Code","Fira Code","Consolas",monospace;font-size:13px;line-height:1.4;overflow-x:auto}.copyable-snippet .copyable-snippet-body code{color:var(--mud-palette-text-primary);white-space:nowrap}.mud-theme-dark .copyable-snippet .copyable-snippet-body{background:rgba(0,0,0,.25)}:not(.mud-theme-dark) .copyable-snippet .copyable-snippet-body{background:rgba(0,0,0,.03)} diff --git a/test/AtcWeb.Domain.Tests/Compliance/ComplianceFilterEngineTests.cs b/test/AtcWeb.Domain.Tests/Compliance/ComplianceFilterEngineTests.cs new file mode 100644 index 0000000..1e848aa --- /dev/null +++ b/test/AtcWeb.Domain.Tests/Compliance/ComplianceFilterEngineTests.cs @@ -0,0 +1,74 @@ +namespace AtcWeb.Domain.Tests.Compliance; + +public sealed class ComplianceFilterEngineTests +{ + [Fact] + public void Apply_ReturnsAll_WhenStateIsEmpty() + { + var data = new[] { Make("atc-rest", "C#"), Make("atc-foo", "Python") }; + ComplianceFilterEngine.Apply(data, new ComplianceFilterState()).Should().HaveCount(2); + } + + [Fact] + public void Apply_FiltersByLanguage() + { + var data = new[] { Make("a", "C#"), Make("b", "Python") }; + var state = new ComplianceFilterState { Language = "C#" }; + ComplianceFilterEngine.Apply(data, state).Should().ContainSingle(s => s.Name == "a"); + } + + [Fact] + public void Apply_FiltersBySearchText_OnNameAndDescription() + { + var data = new[] + { + Make("atc-rest-api-generator", "C#", description: "Generates REST APIs"), + Make("atc-iot", "C#", description: "IoT helpers"), + }; + var state = new ComplianceFilterState { SearchText = "rest" }; + ComplianceFilterEngine.Apply(data, state) + .Should().ContainSingle(s => s.Name.StartsWith("atc-rest", StringComparison.Ordinal)); + } + + [Fact] + public void Apply_FiltersByHealth() + { + var ok = Make("ok", "C#"); + ok.Signals.LicenseIsMit = true; + var bad = Make("bad", "C#"); + bad.Signals.LicenseIsMit = false; + var state = new ComplianceFilterState { Health = HealthStatus.Error }; + ComplianceFilterEngine.Apply(new[] { ok, bad }, state) + .Should().ContainSingle(s => s.Name == "bad"); + } + + [Fact] + public void Apply_FiltersByFailingSignal_TfmBehind() + { + var ok = Make("ok", "C#"); + ok.Signals.GlobalTargetFrameworkIsLatest = true; + var stale = Make("stale", "C#"); + stale.Signals.GlobalTargetFrameworkIsLatest = false; + var state = new ComplianceFilterState { FailingSignals = ["TfmBehind"] }; + ComplianceFilterEngine.Apply(new[] { ok, stale }, state) + .Should().ContainSingle(s => s.Name == "stale"); + } + + private static RepositoryComplianceSummary Make( + string name, + string language, + string? description = null) + => new() + { + Name = name, + Language = language, + LicenseKey = "mit", + Description = description, + Signals = new RepositoryComplianceSignals + { + LicenseIsMit = true, + EditorConfigStatus = new EditorConfigStatus(), + WorkflowsStatus = new WorkflowsStatus(), + }, + }; +} \ No newline at end of file diff --git a/test/AtcWeb.Domain.Tests/Compliance/ComplianceHealthTests.cs b/test/AtcWeb.Domain.Tests/Compliance/ComplianceHealthTests.cs new file mode 100644 index 0000000..027ad64 --- /dev/null +++ b/test/AtcWeb.Domain.Tests/Compliance/ComplianceHealthTests.cs @@ -0,0 +1,69 @@ +namespace AtcWeb.Domain.Tests.Compliance; + +public sealed class ComplianceHealthTests +{ + [Fact] + public void Compute_ReturnsOk_WhenAllGreen() + { + ComplianceHealth.Compute(AllGreenSummary()).Should().Be(HealthStatus.Ok); + } + + [Fact] + public void Compute_ReturnsError_WhenLicenseNotMit() + { + var s = AllGreenSummary(); + s.Signals.LicenseIsMit = false; + ComplianceHealth.Compute(s).Should().Be(HealthStatus.Error); + } + + [Fact] + public void Compute_ReturnsError_WhenJavaSetupPresent() + { + var s = AllGreenSummary(); + s.Signals.WorkflowsStatus.HasJavaSetup = true; + ComplianceHealth.Compute(s).Should().Be(HealthStatus.Error); + } + + [Fact] + public void Compute_ReturnsWarning_WhenReadmeMissingButNoErrors() + { + var s = AllGreenSummary(); + s.Signals.HasGoodReadme = false; + ComplianceHealth.Compute(s).Should().Be(HealthStatus.Warning); + } + + private static RepositoryComplianceSummary AllGreenSummary() => new() + { + Name = "atc-test", + Language = "C#", + LicenseKey = "mit", + Signals = new RepositoryComplianceSignals + { + HasGoodReadme = true, + LicenseIsMit = true, + HomepageIsAtcWeb = true, + UpdaterPresent = true, + UpdaterTargetIsLatest = true, + GlobalLangVersionIsLatest = true, + GlobalTargetFrameworkIsLatest = true, + XunitV3Status = XunitV3Status.Yes, + ReleasePleasePresent = true, + EditorConfigStatus = new EditorConfigStatus + { + RootPresent = true, + RootIsLatest = true, + SrcPresent = true, + SrcIsLatest = true, + TestPresent = true, + TestIsLatest = true, + }, + WorkflowsStatus = new WorkflowsStatus + { + CheckoutIsLatest = true, + SetupDotnetIsLatest = true, + DotnetVersionIsLatest = true, + HasJavaSetup = false, + }, + }, + }; +} \ No newline at end of file diff --git a/test/AtcWeb.Domain.Tests/GlobalUsings.cs b/test/AtcWeb.Domain.Tests/GlobalUsings.cs index 4a5ab73..7ced642 100644 --- a/test/AtcWeb.Domain.Tests/GlobalUsings.cs +++ b/test/AtcWeb.Domain.Tests/GlobalUsings.cs @@ -1,3 +1,5 @@ global using AtcWeb.Domain.AtcApi; +global using AtcWeb.Domain.AtcApi.Models.Compliance; global using AtcWeb.Domain.Caching; +global using AtcWeb.Domain.Compliance; global using Microsoft.Extensions.Caching.Memory; \ No newline at end of file