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");