diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj index d98349cb4f..1a9fe56086 100644 --- a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj +++ b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj @@ -8,10 +8,10 @@ + - diff --git a/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs b/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs deleted file mode 100644 index bfd8214b46..0000000000 --- a/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Foundatio.Storage; -using Minio.DataModel.Args; - -namespace Aspire.Hosting; - -public static class MinIoExtensions -{ - public static IResourceBuilder AddMinIo( - this IDistributedApplicationBuilder builder, - string name, - Action? configure = null) - { - var options = new MinIoBuilder(); - configure?.Invoke(options); - - string bucket = options.Bucket ?? "storage"; - var resource = new MinIoResource(name, options.AccessKey, options.SecretKey, bucket); - string? connectionString; - - builder.Eventing.Subscribe(resource, async (_, ct) => - { - connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct); - - if (connectionString == null) - throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{resource.Name}' resource but the connection string was null."); - - var storage = new MinioFileStorage(o => o.ConnectionString(connectionString)); - try - { - bool found = await storage.Client.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket), ct); - if (!found) - await storage.Client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), ct); - } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - } - }); - - return builder.AddResource(resource) - .WithImage(MinIoContainerImageTags.Image) - .WithImageRegistry(MinIoContainerImageTags.Registry) - .WithImageTag(MinIoContainerImageTags.Tag) - .WithArgs("server", "/data", "--console-address", $":{MinIoResource.DefaultConsolePort}") - .WithEndpoint(port: options.ApiPort, targetPort: MinIoResource.DefaultApiPort, name: MinIoResource.ApiEndpointName) - .WithHttpEndpoint(port: options.ConsolePort, targetPort: MinIoResource.DefaultConsolePort, name: MinIoResource.ConsoleEndpointName) - .WithUrlForEndpoint(MinIoResource.ConsoleEndpointName, u => u.DisplayText = "Storage") - .ConfigureCredentials(options) - .ConfigureVolume(options); - } - - private static IResourceBuilder ConfigureCredentials( - this IResourceBuilder builder, - MinIoBuilder options) - { - return builder - .WithEnvironment("MINIO_ROOT_USER", options.AccessKey ?? "minioadmin") - .WithEnvironment("MINIO_ROOT_PASSWORD", options.SecretKey ?? "minioadmin"); - } - - private static IResourceBuilder ConfigureVolume( - this IResourceBuilder builder, - MinIoBuilder options) - { - if (!string.IsNullOrEmpty(options.DataVolumePath)) - builder = builder.WithVolume(options.DataVolumePath, "/data"); - - return builder; - } -} - -public class MinIoResource(string name, string? accessKey = null, string? secretKey = null, string? bucket = "storage") - : ContainerResource(name), IResourceWithConnectionString -{ - internal const string ApiEndpointName = "api"; - internal const string ConsoleEndpointName = "console"; - internal const int DefaultApiPort = 9000; - internal const int DefaultConsolePort = 9001; - - private EndpointReference? _apiReference; - private EndpointReference? _consoleReference; - - private EndpointReference ApiEndpoint => - _apiReference ??= new EndpointReference(this, ApiEndpointName); - - private EndpointReference ConsoleEndpoint => - _consoleReference ??= new EndpointReference(this, ConsoleEndpointName); - - public ReferenceExpression ConnectionStringExpression => - ReferenceExpression.Create( - $"EndPoint=http://{ApiEndpoint.Property(EndpointProperty.Host)}:{ApiEndpoint.Property(EndpointProperty.Port)};" + - $"AccessKey={AccessKey ?? "minioadmin"};" + - $"SecretKey={SecretKey ?? "minioadmin"};" + - $"Bucket={Bucket};" - ); - - public string? AccessKey { get; } = accessKey; - public string? SecretKey { get; } = secretKey; - public string? Bucket { get; } = bucket; -} - -public class MinIoBuilder -{ - public int? ApiPort { get; set; } - public int? ConsolePort { get; set; } - public string? AccessKey { get; set; } - public string? SecretKey { get; set; } - public string? Bucket { get; set; } - public string? DataVolumePath { get; set; } - - public MinIoBuilder WithPorts(int? apiPort = null, int? consolePort = null) - { - ApiPort = apiPort; - ConsolePort = consolePort; - return this; - } - - public MinIoBuilder WithCredentials(string accessKey, string secretKey) - { - AccessKey = accessKey; - SecretKey = secretKey; - return this; - } - - public MinIoBuilder WithBucket(string bucket) - { - Bucket = bucket; - return this; - } - - public MinIoBuilder WithDataVolume(string path) - { - DataVolumePath = path; - return this; - } -} - -internal static class MinIoContainerImageTags -{ - internal const string Registry = "docker.io"; - internal const string Image = "minio/minio"; - internal const string Tag = "RELEASE.2025-07-23T15-54-02Z"; -} diff --git a/src/Exceptionless.AppHost/Program.cs b/src/Exceptionless.AppHost/Program.cs index 4326c42006..3949c876fc 100644 --- a/src/Exceptionless.AppHost/Program.cs +++ b/src/Exceptionless.AppHost/Program.cs @@ -6,9 +6,16 @@ .WithDataVolume("exceptionless.data.v1") .WithKibana(b => b.WithLifetime(ContainerLifetime.Persistent).WithContainerName("Exceptionless-Kibana")); -var storage = builder.AddMinIo("Storage", s => s.WithCredentials("guest", "password").WithPorts(9000).WithBucket("ex-events")) - .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-Storage"); +var storage = builder.AddAzureStorage("Storage") + .RunAsEmulator(c => + { + c.WithLifetime(ContainerLifetime.Persistent); + c.WithContainerName("Exceptionless-Storage"); + c.WithDataVolume(); + }); + +var storageBlobs = storage.AddBlobs("StorageBlobs"); +var storageQueues = storage.AddQueues("StorageQueues"); var cache = builder.AddRedis("Redis", port: 6379) .WithImageTag("7.4") @@ -28,7 +35,8 @@ builder.AddProject("Jobs", "AllJobs") .WithReference(cache) .WithReference(elastic) - .WithReference(storage, "MinIO") + .WithReference(storageBlobs, "AzureStorage") + .WithReference(storageQueues, "AzureQueues") .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025") .WaitFor(elastic) .WaitFor(cache) @@ -39,7 +47,8 @@ var api = builder.AddProject("Api", "Exceptionless") .WithReference(cache) .WithReference(elastic) - .WithReference(storage, "MinIO") + .WithReference(storageBlobs, "AzureStorage") + .WithReference(storageQueues, "AzureQueues") .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025") .WithEnvironment("RunJobsInProcess", "false") .WaitFor(elastic) diff --git a/src/Exceptionless.Core/Configuration/QueueOptions.cs b/src/Exceptionless.Core/Configuration/QueueOptions.cs index 5955eb25fd..c67ed035a7 100644 --- a/src/Exceptionless.Core/Configuration/QueueOptions.cs +++ b/src/Exceptionless.Core/Configuration/QueueOptions.cs @@ -9,7 +9,7 @@ public class QueueOptions { public string? ConnectionString { get; internal set; } public string? Provider { get; internal set; } - public Dictionary Data { get; internal set; } = null!; + public Dictionary Data { get; internal set; } = new(StringComparer.OrdinalIgnoreCase); public string Scope { get; internal set; } = null!; public string ScopePrefix { get; internal set; } = null!; @@ -18,36 +18,45 @@ public class QueueOptions public static QueueOptions ReadFromConfiguration(IConfiguration config, AppOptions appOptions) { - var options = new QueueOptions { Scope = appOptions.AppScope }; - options.ScopePrefix = !String.IsNullOrEmpty(options.Scope) ? $"{options.Scope}-" : String.Empty; + var options = new QueueOptions + { + Scope = appOptions.AppScope, + ScopePrefix = !String.IsNullOrEmpty(appOptions.AppScope) ? $"{appOptions.AppScope}-" : String.Empty, + MetricsPollingInterval = appOptions.AppMode == AppMode.Development ? TimeSpan.FromSeconds(15) : TimeSpan.FromSeconds(5) + }; string? cs = config.GetConnectionString("Queue"); - if (cs != null) + if (!String.IsNullOrWhiteSpace(cs)) { options.Data = cs.ParseConnectionString(); options.Provider = options.Data.GetString(nameof(options.Provider)); } else { - var redisConnectionString = config.GetConnectionString("Redis"); + string? azureStorageConnectionString = config.GetConnectionString("AzureQueues"); + if (!String.IsNullOrEmpty(azureStorageConnectionString)) + { + options.Provider = "azurestorage"; + options.ConnectionString = azureStorageConnectionString; + options.Data = azureStorageConnectionString.ParseConnectionString(); + return options; + } + + string? redisConnectionString = config.GetConnectionString("Redis"); if (!String.IsNullOrEmpty(redisConnectionString)) { options.Provider = "redis"; + options.ConnectionString = redisConnectionString; + options.Data = redisConnectionString.ParseConnectionString(); + return options; } } string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null; if (!String.IsNullOrEmpty(providerConnectionString)) - { - var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server"); - options.Data ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - options.Data.AddRange(providerOptions); - } + options.Data.AddRange(providerConnectionString.ParseConnectionString()); options.ConnectionString = options.Data.BuildConnectionString(new HashSet { nameof(options.Provider) }); - - options.MetricsPollingInterval = appOptions.AppMode == AppMode.Development ? TimeSpan.FromSeconds(15) : TimeSpan.FromSeconds(5); - return options; } } diff --git a/src/Exceptionless.Core/Configuration/StorageOptions.cs b/src/Exceptionless.Core/Configuration/StorageOptions.cs index 2ccb47faee..713767660f 100644 --- a/src/Exceptionless.Core/Configuration/StorageOptions.cs +++ b/src/Exceptionless.Core/Configuration/StorageOptions.cs @@ -9,41 +9,42 @@ public class StorageOptions { public string? ConnectionString { get; internal set; } public string? Provider { get; internal set; } - public Dictionary Data { get; internal set; } = null!; + public Dictionary Data { get; internal set; } = new(StringComparer.OrdinalIgnoreCase); public string Scope { get; internal set; } = null!; public string ScopePrefix { get; internal set; } = null!; public static StorageOptions ReadFromConfiguration(IConfiguration config, AppOptions appOptions) { - var options = new StorageOptions { Scope = appOptions.AppScope }; - options.ScopePrefix = !String.IsNullOrEmpty(options.Scope) ? $"{options.Scope}-" : String.Empty; + var options = new StorageOptions + { + Scope = appOptions.AppScope, + ScopePrefix = !String.IsNullOrEmpty(appOptions.AppScope) ? $"{appOptions.AppScope}-" : String.Empty + }; string? cs = config.GetConnectionString("Storage"); - if (cs != null) + if (!String.IsNullOrWhiteSpace(cs)) { options.Data = cs.ParseConnectionString(); options.Provider = options.Data.GetString(nameof(options.Provider)); } else { - string? minioConnectionString = config.GetConnectionString("MinIO"); - if (!String.IsNullOrEmpty(minioConnectionString)) + string? azureStorageConnectionString = config.GetConnectionString("AzureStorage"); + if (!String.IsNullOrEmpty(azureStorageConnectionString)) { - options.Provider = "minio"; + options.Provider = "azurestorage"; + options.ConnectionString = azureStorageConnectionString; + options.Data = azureStorageConnectionString.ParseConnectionString(); + return options; } } string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null; if (!String.IsNullOrEmpty(providerConnectionString)) - { - var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server"); - options.Data ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - options.Data.AddRange(providerOptions); - } + options.Data.AddRange(providerConnectionString.ParseConnectionString()); options.ConnectionString = options.Data.BuildConnectionString(new HashSet { nameof(options.Provider) }); - return options; } } diff --git a/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs b/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs index 933ae2c544..d973b3840b 100644 --- a/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs +++ b/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs @@ -4,6 +4,15 @@ namespace Exceptionless.Core.Extensions; public static class DictionaryExtensions { + public static void AddRange(this IDictionary? dictionary, IDictionary? range) + { + if (dictionary is null || range is null) + return; + + foreach (var r in range) + dictionary[r.Key] = r.Value; + } + public static void Trim(this HashSet items, Predicate itemsToRemove, Predicate itemsToAlwaysInclude, int maxLength) { items.RemoveWhere(itemsToRemove); diff --git a/src/Exceptionless.Insulation/Bootstrapper.cs b/src/Exceptionless.Insulation/Bootstrapper.cs index 34713fb956..2301172fc3 100644 --- a/src/Exceptionless.Insulation/Bootstrapper.cs +++ b/src/Exceptionless.Insulation/Bootstrapper.cs @@ -230,17 +230,6 @@ private static void RegisterStorage(IServiceCollection container, StorageOptions return storage; }); } - else if (String.Equals(options.Provider, "minio")) - { - container.ReplaceSingleton(s => new MinioFileStorage(new MinioFileStorageOptions - { - ConnectionString = options.ConnectionString, - Serializer = s.GetRequiredService(), - TimeProvider = s.GetRequiredService(), - ResiliencePolicyProvider = s.GetRequiredService(), - LoggerFactory = s.GetRequiredService() - })); - } else if (String.Equals(options.Provider, "s3")) { container.ReplaceSingleton(s => new S3FileStorage(o => o diff --git a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj index 9cc8ff6666..9d672f4910 100644 --- a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj +++ b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj @@ -5,7 +5,6 @@ - diff --git a/src/Exceptionless.Web/ClientApp/.prettierignore b/src/Exceptionless.Web/ClientApp/.prettierignore index 05a7333c4c..d1ad98febd 100644 --- a/src/Exceptionless.Web/ClientApp/.prettierignore +++ b/src/Exceptionless.Web/ClientApp/.prettierignore @@ -6,5 +6,8 @@ yarn.lock # Generated files src/lib/generated +# Third-party +.agents/ + # UI components (shadcn) src/lib/features/shared/components/ui diff --git a/src/Exceptionless.Web/ClientApp/eslint.config.js b/src/Exceptionless.Web/ClientApp/eslint.config.js index 7bbf909fc5..9bfd564cf6 100644 --- a/src/Exceptionless.Web/ClientApp/eslint.config.js +++ b/src/Exceptionless.Web/ClientApp/eslint.config.js @@ -37,7 +37,7 @@ export default ts.config( } }, { - ignores: ['build/', '.svelte-kit/', 'dist/', 'src/lib/generated/', 'src/lib/features/shared/components/ui/'] + ignores: ['.agents/', 'build/', '.svelte-kit/', 'dist/', 'src/lib/generated/', 'src/lib/features/shared/components/ui/'] }, { rules: { diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 9305b69304..ebb7310d3e 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -19,7 +19,7 @@ - + diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 773637ca70..fa5fdb40d6 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -7405,7 +7405,8 @@ "required": [ "provider", "provider_user_id", - "username" + "username", + "extra_data" ], "type": "object", "properties": { @@ -7419,14 +7420,10 @@ "type": "string" }, "extra_data": { - "type": [ - "null", - "object" - ], + "type": "object", "additionalProperties": { "type": "string" - }, - "readOnly": true + } } } }, @@ -7981,7 +7978,9 @@ "full_name", "email_address", "id", + "organization_ids", "password_reset_token_expiration", + "o_auth_accounts", "email_notifications_enabled", "is_email_address_verified", "verify_email_address_token_expiration", @@ -8005,8 +8004,7 @@ "items": { "type": "string" }, - "description": "The organizations that the user has access to.", - "readOnly": true + "description": "The organizations that the user has access to." }, "password": { "type": [ @@ -8034,8 +8032,7 @@ "type": "array", "items": { "$ref": "#/components/schemas/OAuthAccount" - }, - "readOnly": true + } }, "full_name": { "type": "string", @@ -8658,14 +8655,16 @@ } }, "WorkInProgressResult": { + "required": [ + "workers" + ], "type": "object", "properties": { "workers": { "type": "array", "items": { "type": "string" - }, - "readOnly": true + } } } } diff --git a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs index 1af851c767..7d2c199a3f 100644 --- a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs @@ -10,7 +10,7 @@ public OpenApiControllerTests(ITestOutputHelper output, AppWebHostFactory factor } [Fact] - public async Task GetSwaggerJson_Default_ReturnsExpectedBaseline() + public async Task GetOpenApiJson_Default_ReturnsExpectedBaseline() { // Arrange string baselinePath = Path.Combine(AppContext.BaseDirectory, "Controllers", "Data", "openapi.json");