diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 805a2f76d4..031392b0b5 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -151,6 +151,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -190,6 +191,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddStartupAction(); diff --git a/src/Exceptionless.Core/Models/SystemSettings.cs b/src/Exceptionless.Core/Models/SystemSettings.cs new file mode 100644 index 0000000000..607aaa6029 --- /dev/null +++ b/src/Exceptionless.Core/Models/SystemSettings.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Attributes; +using Exceptionless.Core.Messaging.Models; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Models; + +public sealed class SystemSettings : IIdentity, IHaveDates +{ + public const string DefaultId = "000000000000000000000001"; + + [ObjectId] + public string Id { get; set; } = DefaultId; + + [MaxLength(200)] + public string? AssistantModel { get; set; } + + public bool? AssistantEnabled { get; set; } + + public bool? EventSubmissionEnabled { get; set; } + + public SystemNotification? SystemNotification { get; set; } + + [ObjectId] + public string CreatedByUserId { get; set; } = null!; + + [ObjectId] + public string UpdatedByUserId { get; set; } = null!; + + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs index 7fd742b21b..837632f588 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs @@ -47,6 +47,7 @@ ILoggerFactory loggerFactory AddIndex(OAuthTokens = new OAuthTokenIndex(this)); AddIndex(Projects = new ProjectIndex(this)); AddIndex(SavedViews = new SavedViewIndex(this)); + AddIndex(SystemSettings = new SystemSettingsIndex(this)); AddIndex(Tokens = new TokenIndex(this)); AddIndex(Users = new UserIndex(this)); AddIndex(WebHooks = new WebHookIndex(this)); @@ -77,6 +78,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder) public OAuthTokenIndex OAuthTokens { get; } public ProjectIndex Projects { get; } public SavedViewIndex SavedViews { get; } + public SystemSettingsIndex SystemSettings { get; } public TokenIndex Tokens { get; } public UserIndex Users { get; } public WebHookIndex WebHooks { get; } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/SystemSettingsIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SystemSettingsIndex.cs new file mode 100644 index 0000000000..4f48d72bd7 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SystemSettingsIndex.cs @@ -0,0 +1,45 @@ +using Elastic.Clients.Elasticsearch.IndexManagement; +using Elastic.Clients.Elasticsearch.Mapping; +using Exceptionless.Core.Models; +using Foundatio.Repositories.Elasticsearch.Configuration; +using Foundatio.Repositories.Elasticsearch.Extensions; + +namespace Exceptionless.Core.Repositories.Configuration; + +public sealed class SystemSettingsIndex : VersionedIndex +{ + private readonly ExceptionlessElasticConfiguration _configuration; + + public SystemSettingsIndex(ExceptionlessElasticConfiguration configuration) + : base(configuration, configuration.Options.ScopePrefix + "system-settings", 1) + { + _configuration = configuration; + } + + public override void ConfigureIndexMapping(TypeMappingDescriptor map) + { + map + .Dynamic(DynamicMapping.False) + .Properties(properties => properties + .SetupDefaults() + .Keyword(settings => settings.AssistantModel) + .Boolean(settings => settings.AssistantEnabled) + .Boolean(settings => settings.EventSubmissionEnabled) + .Object(settings => settings.SystemNotification, notification => notification.Properties(properties => properties + .Date("date") + .Text("message") + .Keyword("level") + .Keyword("target"))) + .Keyword(settings => settings.CreatedByUserId) + .Keyword(settings => settings.UpdatedByUserId)); + } + + public override void ConfigureIndex(CreateIndexRequestDescriptor index) + { + base.ConfigureIndex(index); + index.Settings(settings => settings + .NumberOfShards(1) + .NumberOfReplicas(_configuration.Options.NumberOfReplicas) + .Priority(5)); + } +} diff --git a/src/Exceptionless.Core/Repositories/Interfaces/ISystemSettingsRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/ISystemSettingsRepository.cs new file mode 100644 index 0000000000..39eeba1355 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Interfaces/ISystemSettingsRepository.cs @@ -0,0 +1,6 @@ +using Exceptionless.Core.Models; +using Foundatio.Repositories; + +namespace Exceptionless.Core.Repositories; + +public interface ISystemSettingsRepository : IRepository; diff --git a/src/Exceptionless.Core/Repositories/SystemSettingsRepository.cs b/src/Exceptionless.Core/Repositories/SystemSettingsRepository.cs new file mode 100644 index 0000000000..ffb13c5a44 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/SystemSettingsRepository.cs @@ -0,0 +1,15 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Validation; +using Foundatio.Repositories; + +namespace Exceptionless.Core.Repositories; + +public sealed class SystemSettingsRepository : RepositoryBase, ISystemSettingsRepository +{ + public SystemSettingsRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + : base(configuration.SystemSettings, validator, options) + { + DefaultConsistency = Consistency.Immediate; + } +} diff --git a/src/Exceptionless.Core/Services/NotificationService.cs b/src/Exceptionless.Core/Services/NotificationService.cs index 1c89766b9b..64f28d1289 100644 --- a/src/Exceptionless.Core/Services/NotificationService.cs +++ b/src/Exceptionless.Core/Services/NotificationService.cs @@ -7,29 +7,54 @@ namespace Exceptionless.Core.Services; -public class NotificationService(ICacheClient cacheClient, IMessagePublisher messagePublisher, TimeProvider timeProvider, CacheLockProvider lockProvider) +public class NotificationService(ICacheClient cacheClient, IMessagePublisher messagePublisher, TimeProvider timeProvider, CacheLockProvider lockProvider, SystemSettingsService systemSettingsService) { private const string SystemNotificationCacheKey = "system-notification"; + private const string SystemNotificationCompatibilityCacheKey = "system-notification-compatibility"; private static readonly TimeSpan OrganizationNotificationLockTimeout = TimeSpan.FromMinutes(90); public async Task GetSystemNotificationAsync() { - var result = await cacheClient.GetAsync(SystemNotificationCacheKey); - return result.HasValue ? result.Value : null; + var settings = await systemSettingsService.GetAsync(); + var durableNotification = settings?.SystemNotification; + var legacyNotification = await cacheClient.GetAsync(SystemNotificationCacheKey); + + // Reconcile a newer notification written by an older instance during a rolling deployment. + if (legacyNotification.HasValue && (durableNotification is null || legacyNotification.Value.Date > durableNotification.Date)) + return legacyNotification.Value; + + if (durableNotification is null) + return legacyNotification.HasValue ? legacyNotification.Value : null; + + // Older instances clear only the legacy key. A separate marker distinguishes that targeted + // removal from a full cache restart, where Elasticsearch must remain authoritative. + if (!legacyNotification.HasValue) + { + var compatibilityDate = await cacheClient.GetAsync(SystemNotificationCompatibilityCacheKey); + if (compatibilityDate.HasValue && compatibilityDate.Value >= durableNotification.Date) + return null; + } + + return durableNotification; } - public async Task SetSystemNotificationAsync(string message, SystemNotificationLevel level = SystemNotificationLevel.Info, SystemNotificationTarget target = SystemNotificationTarget.Both, bool publish = true) + public async Task SetSystemNotificationAsync(string message, string userId, SystemNotificationLevel level = SystemNotificationLevel.Info, SystemNotificationTarget target = SystemNotificationTarget.Both, bool publish = true) { var notification = new SystemNotification { Date = timeProvider.GetUtcNow().UtcDateTime, Message = message, Level = level, Target = target }; + await systemSettingsService.UpdateAsync(userId, settings => settings.SystemNotification = notification); + // Keep older instances in a rolling deployment synchronized until they all read durable settings. await cacheClient.SetAsync(SystemNotificationCacheKey, notification); + await cacheClient.SetAsync(SystemNotificationCompatibilityCacheKey, notification.Date); if (publish) await messagePublisher.PublishAsync(notification); return notification; } - public async Task ClearSystemNotificationAsync(bool publish = true) + public async Task ClearSystemNotificationAsync(string userId, bool publish = true) { + await systemSettingsService.UpdateAsync(userId, settings => settings.SystemNotification = null); await cacheClient.RemoveAsync(SystemNotificationCacheKey); + await cacheClient.RemoveAsync(SystemNotificationCompatibilityCacheKey); if (publish) await messagePublisher.PublishAsync(new SystemNotification { Date = timeProvider.GetUtcNow().UtcDateTime }); } diff --git a/src/Exceptionless.Core/Services/SystemSettingsService.cs b/src/Exceptionless.Core/Services/SystemSettingsService.cs new file mode 100644 index 0000000000..1899e0e66b --- /dev/null +++ b/src/Exceptionless.Core/Services/SystemSettingsService.cs @@ -0,0 +1,95 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Lock; +using Foundatio.Repositories; + +namespace Exceptionless.Core.Services; + +public sealed class SystemSettingsService +{ + private readonly AppOptions _appOptions; + private readonly Func> _getSettingsAsync; + private readonly ILockProvider? _lockProvider; + private readonly Func _saveSettingsAsync; + private readonly TimeProvider _timeProvider; + + public SystemSettingsService( + ISystemSettingsRepository repository, + ILockProvider lockProvider, + AppOptions appOptions, + TimeProvider timeProvider) + : this( + () => repository.GetByIdAsync(SystemSettings.DefaultId, options => options.Cache()), + async settings => await repository.SaveAsync(settings, options => options.Cache().ImmediateConsistency()), + lockProvider, + appOptions, + timeProvider) + { + } + + internal SystemSettingsService( + Func> getSettingsAsync, + Func saveSettingsAsync, + AppOptions appOptions, + TimeProvider timeProvider) + : this(getSettingsAsync, saveSettingsAsync, null, appOptions, timeProvider) + { + } + + private SystemSettingsService( + Func> getSettingsAsync, + Func saveSettingsAsync, + ILockProvider? lockProvider, + AppOptions appOptions, + TimeProvider timeProvider) + { + _getSettingsAsync = getSettingsAsync; + _saveSettingsAsync = saveSettingsAsync; + _lockProvider = lockProvider; + _appOptions = appOptions; + _timeProvider = timeProvider; + } + + public Task GetAsync() => _getSettingsAsync(); + + public async Task UpdateAsync(string userId, Action update) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + ArgumentNullException.ThrowIfNull(update); + + if (_lockProvider is null) + return await UpdateCoreAsync(userId, update); + + await using var settingsLock = await _lockProvider.AcquireAsync("system-settings:update", TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15)); + return await UpdateCoreAsync(userId, update); + } + + private async Task UpdateCoreAsync(string userId, Action update) + { + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + var settings = await _getSettingsAsync() ?? new SystemSettings + { + CreatedByUserId = userId, + CreatedUtc = utcNow + }; + + update(settings); + settings.UpdatedByUserId = userId; + settings.UpdatedUtc = utcNow; + await _saveSettingsAsync(settings); + + return settings; + } + + public async Task IsAssistantEnabledAsync() + { + var settings = await _getSettingsAsync(); + return settings?.AssistantEnabled ?? _appOptions.AssistantOptions.Enabled; + } + + public async Task IsEventSubmissionEnabledAsync() + { + var settings = await _getSettingsAsync(); + return settings?.EventSubmissionEnabled ?? !_appOptions.EventSubmissionDisabled; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index e6d7f335bc..7d6bbbbfe4 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -1,9 +1,15 @@ +using Exceptionless.Core; using Exceptionless.Core.Authorization; +using Exceptionless.Core.Services; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Messages; using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Assistant; +using Exceptionless.Web.Extensions; using Exceptionless.Web.Models.Admin; using Foundatio.Mediator; +using Microsoft.AspNetCore.Mvc; +using HttpResults = Microsoft.AspNetCore.Http.Results; using HttpIResult = Microsoft.AspNetCore.Http.IResult; namespace Exceptionless.Web.Api.Endpoints; @@ -20,6 +26,59 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder group.MapGet("echo", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) => (await mediator.InvokeAsync>(new GetAdminEcho(httpContext))).ToHttpResult(resultMapper)); + endpoints.MapGet("api/v2/admin/assistant-settings", async (AssistantModelSettingsService settingsService) + => HttpResults.Ok(await settingsService.GetAsync())) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .WithTags(nameof(AdminEndpoints)) + .WithSummary("Get Exie assistant settings"); + + endpoints.MapPut("api/v2/admin/assistant-settings", async (HttpContext httpContext, [FromBody] UpdateAssistantSettings request, AssistantModelSettingsService settingsService) + => HttpResults.Ok(await settingsService.SetModelAsync(request.Model, httpContext.Request.GetUser().Id))) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Accepts("application/json", "application/*+json") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithTags(nameof(AdminEndpoints)) + .WithSummary("Update Exie assistant settings"); + + endpoints.MapPut("api/v2/admin/assistant-settings/enabled", async (HttpContext httpContext, [FromBody] UpdateAssistantEnabledSettings request, AssistantModelSettingsService settingsService) + => HttpResults.Ok(await settingsService.SetEnabledAsync(request.Enabled, httpContext.Request.GetUser().Id))) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Accepts("application/json", "application/*+json") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithTags(nameof(AdminEndpoints)) + .WithSummary("Update Exie assistant availability"); + + endpoints.MapGet("api/v2/admin/event-submission-settings", GetEventSubmissionSettingsAsync) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .WithTags(nameof(AdminEndpoints)) + .WithSummary("Get event submission settings"); + + endpoints.MapPut("api/v2/admin/event-submission-settings", UpdateEventSubmissionSettingsAsync) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Accepts("application/json", "application/*+json") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithTags(nameof(AdminEndpoints)) + .WithSummary("Update event submission settings"); + endpoints.MapGet("api/v2/admin/assistant-usage", GetAssistantUsageAsync) .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) .AddEndpointFilter() @@ -47,4 +106,28 @@ private static async Task GetAssistantUsageAsync( DateTime? month = null, int limit = 100) => (await mediator.InvokeAsync>(new GetAdminAssistantUsage(month, limit, httpContext))).ToHttpResult(resultMapper); + + private static async Task GetEventSubmissionSettingsAsync(SystemSettingsService settingsService, AppOptions appOptions) + { + var settings = await settingsService.GetAsync(); + return HttpResults.Ok(CreateEventSubmissionSettings(settings?.EventSubmissionEnabled, appOptions)); + } + + private static async Task UpdateEventSubmissionSettingsAsync( + HttpContext httpContext, + [FromBody] UpdateEventSubmissionSettings request, + SystemSettingsService settingsService, + AppOptions appOptions) + { + bool configuredEnabled = !appOptions.EventSubmissionDisabled; + bool? enabledOverride = request.Enabled == configuredEnabled ? null : request.Enabled; + var settings = await settingsService.UpdateAsync(httpContext.Request.GetUser().Id, value => value.EventSubmissionEnabled = enabledOverride); + return HttpResults.Ok(CreateEventSubmissionSettings(settings.EventSubmissionEnabled, appOptions)); + } + + private static EventSubmissionSettings CreateEventSubmissionSettings(bool? enabledOverride, AppOptions appOptions) + { + bool configuredEnabled = !appOptions.EventSubmissionDisabled; + return new EventSubmissionSettings(enabledOverride ?? configuredEnabled, configuredEnabled, enabledOverride.HasValue); + } } diff --git a/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs index d23c19f74e..e542dd512c 100644 --- a/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/StatusEndpoints.cs @@ -2,6 +2,7 @@ using Exceptionless.Core.Messaging.Models; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Extensions; using Exceptionless.Web.Models; using Foundatio.Mediator; using Microsoft.AspNetCore.Mvc; @@ -39,19 +40,19 @@ public static IEndpointRouteBuilder MapStatusEndpoints(this IEndpointRouteBuilde return result.Date == DateTime.MinValue ? HttpResults.NoContent() : HttpResults.Ok(result); }); - group.MapPost("notifications/system", async (IMediator mediator, [FromBody] SetSystemNotificationRequest request, bool publish = true) => + group.MapPost("notifications/system", async (HttpContext httpContext, IMediator mediator, [FromBody] SetSystemNotificationRequest request, bool publish = true) => { if (String.IsNullOrWhiteSpace(request.Message)) return HttpResults.NotFound(); - var result = await mediator.InvokeAsync(new PostSystemNotification(request.Message, request.Level, request.Target, publish)); + var result = await mediator.InvokeAsync(new PostSystemNotification(request.Message, httpContext.Request.GetUser().Id, request.Level, request.Target, publish)); return HttpResults.Ok(result); }) .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy); - group.MapDelete("notifications/system", async (IMediator mediator, bool publish = true) => + group.MapDelete("notifications/system", async (HttpContext httpContext, IMediator mediator, bool publish = true) => { - await mediator.InvokeAsync(new RemoveSystemNotification(publish)); + await mediator.InvokeAsync(new RemoveSystemNotification(httpContext.Request.GetUser().Id, publish)); return HttpResults.Ok(); }) .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy); diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index c1bdb6d5c1..65c87f3c09 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -47,6 +47,7 @@ public class EventHandler( PersistentEventQueryValidator validator, EventStackQueryValidator stackModeValidator, AppOptions appOptions, + SystemSettingsService systemSettingsService, UsageService usageService, TimeProvider timeProvider, LinkGenerator linkGenerator, @@ -395,7 +396,7 @@ public async Task Handle(LegacyPatchEvent message) public async Task Handle(RecordEventHeartbeat message) { var httpContext = message.Context; - if (appOptions.EventSubmissionDisabled || String.IsNullOrEmpty(message.Id)) + if (!await systemSettingsService.IsEventSubmissionEnabledAsync() || String.IsNullOrEmpty(message.Id)) return Result.Success(); string? projectId = httpContext.Request.GetDefaultProjectId(); diff --git a/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs b/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs index 495ae4fc51..5adc445000 100644 --- a/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StatusHandler.cs @@ -89,11 +89,11 @@ public async Task Handle(PostSystemNotification message) if (String.IsNullOrWhiteSpace(message.Message)) return new SystemNotification { Date = DateTime.MinValue }; - return await notificationService.SetSystemNotificationAsync(message.Message, message.Level, message.Target, message.Publish); + return await notificationService.SetSystemNotificationAsync(message.Message, message.UserId, message.Level, message.Target, message.Publish); } public Task Handle(RemoveSystemNotification message) { - return notificationService.ClearSystemNotificationAsync(message.Publish); + return notificationService.ClearSystemNotificationAsync(message.UserId, message.Publish); } } diff --git a/src/Exceptionless.Web/Api/Messages/StatusMessages.cs b/src/Exceptionless.Web/Api/Messages/StatusMessages.cs index 1e859becf4..3edb644bbc 100644 --- a/src/Exceptionless.Web/Api/Messages/StatusMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/StatusMessages.cs @@ -6,8 +6,8 @@ public record GetAboutInfo; public record GetQueueStats; public record PostReleaseNotification(string Message, bool Critical); public record GetSystemNotification; -public record PostSystemNotification(string Message, SystemNotificationLevel Level = SystemNotificationLevel.Info, SystemNotificationTarget Target = SystemNotificationTarget.Both, bool Publish = true); -public record RemoveSystemNotification(bool Publish = true); +public record PostSystemNotification(string Message, string UserId, SystemNotificationLevel Level = SystemNotificationLevel.Info, SystemNotificationTarget Target = SystemNotificationTarget.Both, bool Publish = true); +public record RemoveSystemNotification(string UserId, bool Publish = true); public record ValidateSearchQuery(string Query); public record SetSystemNotificationRequest diff --git a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs index 44144a3528..576312cf28 100644 --- a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs @@ -2,6 +2,7 @@ using Exceptionless.Core.Billing; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.Web.Extensions; using Foundatio.Repositories; using Foundatio.Repositories.Options; @@ -11,13 +12,15 @@ namespace Exceptionless.Web.Assistant; public sealed class AssistantAccessService( AppOptions appOptions, BillingPlans billingPlans, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + SystemSettingsService systemSettingsService) { public async Task GetAccessAsync(HttpRequest request, string? organizationId) { ArgumentNullException.ThrowIfNull(request); - var configurationDecision = EvaluateConfiguration(appOptions); + bool enabled = await systemSettingsService.IsAssistantEnabledAsync(); + var configurationDecision = EvaluateConfiguration(appOptions, enabled); if (configurationDecision is not null) return configurationDecision; @@ -41,9 +44,9 @@ public async Task GetAccessAsync(HttpRequest request, s return EvaluatePlan(billingPlans.GetPlan(organization.PlanId)?.Assistant, billingPlans.MediumPlan.Id); } - internal static AssistantAccessDecision? EvaluateConfiguration(AppOptions appOptions) + internal static AssistantAccessDecision? EvaluateConfiguration(AppOptions appOptions, bool? enabled = null) { - if (!appOptions.AssistantOptions.Enabled) + if (!(enabled ?? appOptions.AssistantOptions.Enabled)) return AssistantAccessDecision.Unavailable(AssistantAccessReason.Disabled, "Exie is disabled.", enabled: false); if (!appOptions.AssistantOptions.IsConfigured) diff --git a/src/Exceptionless.Web/Assistant/AssistantModelSettingsService.cs b/src/Exceptionless.Web/Assistant/AssistantModelSettingsService.cs new file mode 100644 index 0000000000..20f2b559a6 --- /dev/null +++ b/src/Exceptionless.Web/Assistant/AssistantModelSettingsService.cs @@ -0,0 +1,72 @@ +using Exceptionless.Core; +using Exceptionless.Core.Models; +using Exceptionless.Core.Services; + +namespace Exceptionless.Web.Assistant; + +public sealed class AssistantModelSettingsService +{ + private readonly AppOptions _appOptions; + private readonly SystemSettingsService _systemSettingsService; + + public AssistantModelSettingsService( + SystemSettingsService systemSettingsService, + AppOptions appOptions) + { + _systemSettingsService = systemSettingsService; + _appOptions = appOptions; + } + + public async Task GetAsync() => CreateResponse(await _systemSettingsService.GetAsync()); + + public async Task SetModelAsync(string? model, string userId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + + string? normalizedModel = model?.Trim(); + if (String.IsNullOrWhiteSpace(normalizedModel) + || String.Equals(normalizedModel, _appOptions.AssistantOptions.Model, StringComparison.Ordinal)) + normalizedModel = null; + + var settings = await _systemSettingsService.UpdateAsync(userId, value => value.AssistantModel = normalizedModel); + + return CreateResponse(settings); + } + + public async Task SetEnabledAsync(bool? enabled, string userId) + { + bool? normalizedEnabled = enabled; + if (normalizedEnabled == _appOptions.AssistantOptions.Enabled) + normalizedEnabled = null; + + var settings = await _systemSettingsService.UpdateAsync(userId, value => value.AssistantEnabled = normalizedEnabled); + return CreateResponse(settings); + } + + private AssistantModelSettings CreateResponse(SystemSettings? settings) + { + string configuredModel = _appOptions.AssistantOptions.Model; + string? modelOverride = settings?.AssistantModel; + bool isModelOverridden = !String.IsNullOrWhiteSpace(modelOverride); + bool configuredEnabled = _appOptions.AssistantOptions.Enabled; + bool? enabledOverride = settings?.AssistantEnabled; + + return new AssistantModelSettings( + isModelOverridden ? modelOverride! : configuredModel, + configuredModel, + isModelOverridden, + enabledOverride ?? configuredEnabled, + configuredEnabled, + enabledOverride.HasValue, + _appOptions.AssistantOptions.IsConfigured); + } +} + +public sealed record AssistantModelSettings( + string Model, + string ConfiguredModel, + bool IsOverridden, + bool Enabled, + bool ConfiguredEnabled, + bool IsEnabledOverridden, + bool IsConfigured); diff --git a/src/Exceptionless.Web/Assistant/AssistantService.cs b/src/Exceptionless.Web/Assistant/AssistantService.cs index 0a8a57f514..c52de20f52 100644 --- a/src/Exceptionless.Web/Assistant/AssistantService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantService.cs @@ -17,6 +17,7 @@ public sealed class AssistantService( ExceptionlessMcpTools tools, AssistantToolContext assistantToolContext, AssistantConversationService assistantConversationService, + AssistantModelSettingsService assistantModelSettingsService, AssistantUsageService assistantUsageService, TimeProvider timeProvider, ILogger logger) @@ -42,6 +43,7 @@ public async IAsyncEnumerable StreamAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { var options = appOptions.AssistantOptions; + string model = (await assistantModelSettingsService.GetAsync()).Model; AssistantConversationState? conversationState = null; if (!String.IsNullOrWhiteSpace(request.OrganizationId) && !String.IsNullOrWhiteSpace(request.ConversationId)) { @@ -112,7 +114,7 @@ public async IAsyncEnumerable StreamAsync( } await using var providerRequest = await assistantUsageService.StartProviderRequestAsync(request.OrganizationId, providerInputCharacters); - using var response = await SendRequestAsync(messages, options, allowTools, request, cancellationToken); + using var response = await SendRequestAsync(messages, options, model, allowTools, request, cancellationToken); providerRequest.MarkAccepted(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var reader = new StreamReader(stream); @@ -382,7 +384,7 @@ await assistantConversationService.AppendToolResultsAsync( } } - private async Task SendRequestAsync(List messages, AssistantOptions options, bool allowTools, AssistantChatRequest chatRequest, CancellationToken cancellationToken) + private async Task SendRequestAsync(List messages, AssistantOptions options, string model, bool allowTools, AssistantChatRequest chatRequest, CancellationToken cancellationToken) { var client = httpClientFactory.CreateClient(nameof(AssistantService)); using var providerRequest = new HttpRequestMessage(HttpMethod.Post, options.Endpoint); @@ -391,7 +393,7 @@ private async Task SendRequestAsync(List messages, providerRequest.Headers.TryAddWithoutValidation("X-OpenRouter-Title", "Exceptionless"); var payload = new Dictionary { - ["model"] = options.Model, + ["model"] = model, ["messages"] = messages, ["stream"] = true, ["max_tokens"] = AssistantLimits.MaximumOutputTokens, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 9e9e67e95c..63f51aab4f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -1,15 +1,21 @@ +import { invalidateAssistantAccessQueries } from '$features/assistant/api.svelte'; import { type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import type { + AdminAssistantSettings, AdminAssistantUsage, + AdminEventSubmissionSettings, AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, MigrationsResponse, OAuthApplication, OAuthApplicationRequest, - PredefinedSavedViewDefinition + PredefinedSavedViewDefinition, + UpdateAssistantEnabledSettingsRequest, + UpdateAssistantSettingsRequest, + UpdateEventSubmissionSettingsRequest } from './models'; export type RunMaintenanceJobParams = { @@ -20,8 +26,10 @@ export type RunMaintenanceJobParams = { }; export const queryKeys = { + assistantSettings: ['admin', 'assistant-settings'] as const, assistantUsage: (month: string) => ['admin', 'assistant-usage', month] as const, elasticsearch: ['admin', 'elasticsearch'] as const, + eventSubmissionSettings: ['admin', 'event-submission-settings'] as const, migrations: ['admin', 'migrations'] as const, oauthApplications: ['admin', 'oauth-applications'] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, @@ -48,6 +56,25 @@ export function deleteOAuthApplicationMutation() { })); } +export function getAdminAssistantSettingsQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/assistant-settings', { + signal + }); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + queryKey: queryKeys.assistantSettings, + staleTime: 30 * 1000 + })); +} + export function getAdminAssistantUsageQuery(month: () => string) { return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { @@ -116,6 +143,25 @@ export function getElasticsearchSnapshotsQuery() { })); } +export function getEventSubmissionSettingsQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/event-submission-settings', { + signal + }); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + queryKey: queryKeys.eventSubmissionSettings, + staleTime: 30 * 1000 + })); +} + export function getMigrationsQuery() { return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { @@ -207,6 +253,67 @@ export function postOAuthApplicationMutation() { })); } +export function putAdminAssistantEnabledSettingsMutation() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + mutationFn: async (request) => { + const client = useFetchClient(); + const response = await client.putJSON('admin/assistant-settings/enabled', request); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + onSuccess: async (settings) => { + queryClient.setQueryData(queryKeys.assistantSettings, settings); + await invalidateAssistantAccessQueries(queryClient); + } + })); +} + +export function putAdminAssistantSettingsMutation() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + mutationFn: async (request) => { + const client = useFetchClient(); + const response = await client.putJSON('admin/assistant-settings', request); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + onSuccess: (settings) => { + queryClient.setQueryData(queryKeys.assistantSettings, settings); + } + })); +} + +export function putEventSubmissionSettingsMutation() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + mutationFn: async (request) => { + const client = useFetchClient(); + const response = await client.putJSON('admin/event-submission-settings', request); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + onSuccess: (settings) => { + queryClient.setQueryData(queryKeys.eventSubmissionSettings, settings); + } + })); +} + export function putOAuthApplicationMutation() { const queryClient = useQueryClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index e0c6211a52..e5bd45b444 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -1,4 +1,11 @@ -import type { CountResult } from '$generated/api'; +import type { + AssistantModelSettings, + CountResult, + EventSubmissionSettings, + UpdateAssistantEnabledSettings, + UpdateAssistantSettings, + UpdateEventSubmissionSettings +} from '$generated/api'; export enum MigrationType { Versioned = 0, @@ -30,6 +37,8 @@ export type AdminAssistantOrganizationUsage = { turns: number; }; +export type AdminAssistantSettings = AssistantModelSettings; + export type AdminAssistantUsage = { active_organizations: number; completion_tokens: number; @@ -40,6 +49,8 @@ export type AdminAssistantUsage = { turns: number; }; +export type AdminEventSubmissionSettings = EventSubmissionSettings; + export type AdminStats = { events: CountResult; organizations: CountResult; @@ -171,6 +182,10 @@ export type ShardMetric = { value: number; }; +export type UpdateAssistantEnabledSettingsRequest = UpdateAssistantEnabledSettings; +export type UpdateAssistantSettingsRequest = UpdateAssistantSettings; +export type UpdateEventSubmissionSettingsRequest = UpdateEventSubmissionSettings; + export const maintenanceActions: MaintenanceAction[] = [ { category: 'Elasticsearch', diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.test.ts new file mode 100644 index 0000000000..0f0de53e79 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { AssistantSettingsSchema } from './schemas'; + +describe('assistant settings schema', () => { + it('accepts the GLM 5.3 Flash OpenRouter model ID', () => { + const result = AssistantSettingsSchema.safeParse({ model: 'z-ai/glm-5.3-flash' }); + + expect(result.success).toBe(true); + }); + + it('rejects an empty model ID', () => { + const result = AssistantSettingsSchema.safeParse({ model: ' ' }); + + expect(result.success).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts index 9e77203415..84feb8e026 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts @@ -1,5 +1,10 @@ import { array, boolean, date, type infer as Infer, object, string } from 'zod'; +export const AssistantSettingsSchema = object({ + model: string().trim().min(1, 'Enter an OpenRouter model ID.').max(200) +}); +export type AssistantSettingsFormData = Infer; + export const RunMaintenanceJobSchema = object({ confirmText: string().min(1), organizationId: string().optional(), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 2f8965a728..d009f96c39 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -15,6 +15,98 @@ export enum BillingStatus { Unpaid = 4, } +export interface AdminAssistantOrganizationUsage { + organization_id: string; + organization_name: string; + plan_id: string; + /** @format date-time */ + last_used_utc: string; + /** @format int64 */ + turns: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + failed: number; + /** @format int64 */ + cancelled: number; + /** @format int64 */ + provider_requests: number; + /** @format int64 */ + tool_calls: number; + /** @format int64 */ + prompt_tokens: number; + /** @format int64 */ + completion_tokens: number; + /** @format double */ + cost_usd: number; + /** @format int64 */ + blocked_by_concurrency: number; + /** @format int64 */ + blocked_by_rate_limit: number; + /** @format int64 */ + blocked_by_token_limit: number; + /** @format int64 */ + blocked_by_cost_limit: number; + /** @format int64 */ + monthly_token_limit?: null | number; + /** @format double */ + monthly_cost_limit_usd?: null | number; + /** @format double */ + token_utilization?: null | number; + /** @format double */ + cost_utilization?: null | number; +} + +export interface AdminAssistantUsageResponse { + /** @format date-time */ + month: string; + /** @format int64 */ + active_organizations: number; + /** @format int64 */ + turns: number; + /** @format int64 */ + prompt_tokens: number; + /** @format int64 */ + completion_tokens: number; + /** @format double */ + cost_usd: number; + organizations: AdminAssistantOrganizationUsage[]; +} + +export interface AssistantAccessResponse { + enabled: boolean; + has_access: boolean; + upgrade_required: boolean; + message?: null | string; + minimum_plan_id?: null | string; +} + +export interface AssistantChatMessage { + role: string; + content: string; + is_suggested_action?: null | boolean; + suggested_action_label?: null | string; + suggested_action_path?: null | string; +} + +export interface AssistantChatRequest { + messages: AssistantChatMessage[]; + organization_id?: null | string; + project_id?: null | string; + path?: null | string; + conversation_id?: null | string; +} + +export interface AssistantModelSettings { + model: string; + configured_model: string; + is_overridden: boolean; + enabled: boolean; + configured_enabled: boolean; + is_enabled_overridden: boolean; + is_configured: boolean; +} + export interface BillingPlan { id: string; name: string; @@ -63,6 +155,12 @@ export interface CountResult { data?: null | object; } +export interface EventSubmissionSettings { + enabled: boolean; + configured_enabled: boolean; + is_overridden: boolean; +} + export interface ExternalAuthInfo { clientId: string; code: string; @@ -71,6 +169,16 @@ export interface ExternalAuthInfo { inviteToken?: null | string; } +export interface HttpValidationProblemDetails { + type?: null | string; + title?: null | string; + /** @format int32 */ + status?: null | number; + detail?: null | string; + instance?: null | string; + errors: Record; +} + /** Base interface for aggregation results. Concrete types include ValueAggregate, BucketAggregate, StatsAggregate, etc. See client-side type definitions for full type information. */ export interface IAggregate { /** Additional data associated with the aggregate. */ @@ -517,6 +625,14 @@ export interface TokenResult { token: string; } +export interface UpdateAssistantEnabledSettings { + enabled?: null | boolean; +} + +export interface UpdateAssistantSettings { + model?: null | string; +} + export interface UpdateEmailAddressResult { is_verified: boolean; } @@ -528,6 +644,10 @@ export interface UpdateEvent { description?: null | string; } +export interface UpdateEventSubmissionSettings { + enabled?: null | boolean; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateProject { name: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 2e37b6a941..f7e0970212 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -35,6 +35,107 @@ export const BillingStatusSchema = union([ literal(4), ]); +export const AdminAssistantOrganizationUsageSchema = object({ + organization_id: string().min(1, "Organization id is required"), + organization_name: string().min(1, "Organization name is required"), + plan_id: string().min(1, "Plan id is required"), + last_used_utc: iso.datetime(), + turns: int(), + completed: int(), + failed: int(), + cancelled: int(), + provider_requests: int(), + tool_calls: int(), + prompt_tokens: int(), + completion_tokens: int(), + cost_usd: number(), + blocked_by_concurrency: int(), + blocked_by_rate_limit: int(), + blocked_by_token_limit: int(), + blocked_by_cost_limit: int(), + monthly_token_limit: int().nullable(), + monthly_cost_limit_usd: number().nullable(), + token_utilization: number().nullable(), + cost_utilization: number().nullable(), +}); +export type AdminAssistantOrganizationUsageFormData = Infer< + typeof AdminAssistantOrganizationUsageSchema +>; + +export const AdminAssistantUsageResponseSchema = object({ + month: iso.datetime(), + active_organizations: int(), + turns: int(), + prompt_tokens: int(), + completion_tokens: int(), + cost_usd: number(), + organizations: array(lazy(() => AdminAssistantOrganizationUsageSchema)), +}); +export type AdminAssistantUsageResponseFormData = Infer< + typeof AdminAssistantUsageResponseSchema +>; + +export const AssistantAccessResponseSchema = object({ + enabled: boolean(), + has_access: boolean(), + upgrade_required: boolean(), + message: string().min(1, "Message is required").nullable().optional(), + minimum_plan_id: string() + .min(1, "Minimum plan id is required") + .nullable() + .optional(), +}); +export type AssistantAccessResponseFormData = Infer< + typeof AssistantAccessResponseSchema +>; + +export const AssistantChatMessageSchema = object({ + role: string().min(1, "Role is required"), + content: string().min(1, "Content is required"), + is_suggested_action: boolean().nullable().optional(), + suggested_action_label: string() + .min(1, "Suggested action label is required") + .nullable() + .optional(), + suggested_action_path: string() + .min(1, "Suggested action path is required") + .nullable() + .optional(), +}); +export type AssistantChatMessageFormData = Infer< + typeof AssistantChatMessageSchema +>; + +export const AssistantChatRequestSchema = object({ + messages: array(lazy(() => AssistantChatMessageSchema)), + organization_id: string() + .min(1, "Organization id is required") + .nullable() + .optional(), + project_id: string().min(1, "Project id is required").nullable().optional(), + path: string().min(1, "Path is required").nullable().optional(), + conversation_id: string() + .min(1, "Conversation id is required") + .nullable() + .optional(), +}); +export type AssistantChatRequestFormData = Infer< + typeof AssistantChatRequestSchema +>; + +export const AssistantModelSettingsSchema = object({ + model: string().min(1, "Model is required"), + configured_model: string().min(1, "Configured model is required"), + is_overridden: boolean(), + enabled: boolean(), + configured_enabled: boolean(), + is_enabled_overridden: boolean(), + is_configured: boolean(), +}); +export type AssistantModelSettingsFormData = Infer< + typeof AssistantModelSettingsSchema +>; + export const BillingPlanSchema = object({ id: string().min(1, "Id is required"), name: string().min(1, "Name is required"), @@ -96,6 +197,15 @@ export const CountResultSchema = object({ }); export type CountResultFormData = Infer; +export const EventSubmissionSettingsSchema = object({ + enabled: boolean(), + configured_enabled: boolean(), + is_overridden: boolean(), +}); +export type EventSubmissionSettingsFormData = Infer< + typeof EventSubmissionSettingsSchema +>; + export const ExternalAuthInfoSchema = object({ clientId: string().min(1, "Client id is required"), code: string().min(1, "Code is required"), @@ -107,6 +217,18 @@ export const ExternalAuthInfoSchema = object({ }); export type ExternalAuthInfoFormData = Infer; +export const HttpValidationProblemDetailsSchema = object({ + type: string().min(1, "Type is required").nullable().optional(), + title: string().min(1, "Title is required").nullable().optional(), + status: int32().nullable().optional(), + detail: string().min(1, "Detail is required").nullable().optional(), + instance: string().min(1, "Instance is required").nullable().optional(), + errors: record(string(), array(string())), +}); +export type HttpValidationProblemDetailsFormData = Infer< + typeof HttpValidationProblemDetailsSchema +>; + export const IAggregateSchema = object({ data: record(string(), unknown()).optional(), }); @@ -608,6 +730,24 @@ export const TokenResultSchema = object({ }); export type TokenResultFormData = Infer; +export const UpdateAssistantEnabledSettingsSchema = object({ + enabled: boolean().nullable().optional(), +}); +export type UpdateAssistantEnabledSettingsFormData = Infer< + typeof UpdateAssistantEnabledSettingsSchema +>; + +export const UpdateAssistantSettingsSchema = object({ + model: string() + .min(1, "Model is required") + .max(200, "Model must be at most 200 characters") + .nullable() + .optional(), +}); +export type UpdateAssistantSettingsFormData = Infer< + typeof UpdateAssistantSettingsSchema +>; + export const UpdateEmailAddressResultSchema = object({ is_verified: boolean(), }); @@ -621,6 +761,13 @@ export const UpdateEventSchema = object({ }); export type UpdateEventFormData = Infer; +export const UpdateEventSubmissionSettingsSchema = object({ + enabled: boolean().nullable().optional(), +}); +export type UpdateEventSubmissionSettingsFormData = Infer< + typeof UpdateEventSubmissionSettingsSchema +>; + export const UpdateProjectSchema = object({ name: string().min(1, "Name is required").optional(), delete_bot_data_enabled: boolean().optional(), @@ -706,9 +853,7 @@ export const UserSchema = object({ .optional(), password_reset_token_expiration: iso.datetime(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), - organization_preferences: array( - lazy(() => UserOrganizationPreferenceSchema), - ), + organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -753,9 +898,7 @@ export const ViewCurrentUserSchema = object({ hash: string().min(1, "Hash is required").nullable().optional(), has_local_account: boolean(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), - organization_preferences: array( - lazy(() => UserOrganizationPreferenceSchema), - ), + organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte index 495b5ebf7b..09997c4761 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/exie/+page.svelte @@ -1,23 +1,40 @@ -
+
+ + +
+ Availability + {#if settings} + + {settings.is_enabled_overridden ? 'Runtime override' : 'Deployment default'} + + {/if} +
+ Enable or disable Exie for all organizations without restarting the app. +
+ {#if settingsQuery.isPending} + + + Loading Exie availability... + + {:else if settingsQuery.isError} + +

Failed to load Exie availability.

+
+ {:else} + +
+
+ + Disabled Exie requests are rejected immediately. The deployment default is {settings?.configured_enabled + ? 'enabled' + : 'disabled'}. +
+ +
+ {#if settings && !settings.is_configured} +

An OpenRouter API key must be configured before Exie can be used.

+ {/if} +
+ + {#if settings?.is_enabled_overridden} + + {/if} + + + {/if} +
+ + + +
+ Model Configuration + {#if settings} + + {settings.is_overridden ? 'Runtime override' : 'Deployment default'} + + {/if} +
+ Choose the OpenRouter model used for new Exie conversations and turns. Changes apply without restarting the app. +
+ {#if settingsQuery.isPending} + + + Loading model configuration... + + {:else if settingsQuery.isError} + +

Failed to load the Exie model configuration.

+
+ {:else} +
{ + event.preventDefault(); + void settingsForm.handleSubmit(); + }} + > + + + + {#snippet children(field)} + + OpenRouter model ID + field.handleChange(event.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + autocomplete="off" + placeholder="z-ai/glm-5.3-flash" + /> + + Enter a model slug such as + z-ai/glm-5.3-flash. The + deployment default is {settings?.configured_model}. + + + + {/snippet} + + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + + + {#if settings?.is_overridden} + + {/if} + state.isSubmitting}> + {#snippet children(isSubmitting)} + + {/snippet} + + +
+ {/if} +
+
Monthly Exie usage, provider cost, and plan-limit health across all organizations