diff --git a/app/MindWork AI Studio/Components/MSGComponentBase.cs b/app/MindWork AI Studio/Components/MSGComponentBase.cs index 739f6c68..3e1462a1 100644 --- a/app/MindWork AI Studio/Components/MSGComponentBase.cs +++ b/app/MindWork AI Studio/Components/MSGComponentBase.cs @@ -13,10 +13,6 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus [Inject] protected MessageBus MessageBus { get; init; } = null!; - [Inject] - // ReSharper disable once UnusedAutoPropertyAccessor.Local - private ILogger Logger { get; init; } = null!; - private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; #region Overrides of ComponentBase @@ -45,19 +41,22 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus public async Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) { - switch (triggeredEvent) + await this.InvokeAsync(async () => { - case Event.COLOR_THEME_CHANGED: - this.StateHasChanged(); - break; + switch (triggeredEvent) + { + case Event.COLOR_THEME_CHANGED: + this.StateHasChanged(); + break; - case Event.PLUGINS_RELOADED: - this.Lang = await this.SettingsManager.GetActiveLanguagePlugin(); - await this.InvokeAsync(this.StateHasChanged); - break; - } + case Event.PLUGINS_RELOADED: + this.Lang = await this.SettingsManager.GetActiveLanguagePlugin(); + await this.InvokeAsync(this.StateHasChanged); + break; + } - await this.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); + await this.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); + }); } public async Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index d95b8d6f..385ba5df 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using DialogOptions = AIStudio.Dialogs.DialogOptions; +using EnterpriseEnvironment = AIStudio.Tools.EnterpriseEnvironment; namespace AIStudio.Layout; @@ -138,90 +139,100 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan public async Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, TMessage? data) { - switch (triggeredEvent) + await this.InvokeAsync(async () => { - case Event.UPDATE_AVAILABLE: - if (data is UpdateResponse updateResponse) - { - this.currentUpdateResponse = updateResponse; - var message = string.Format(T("An update to version {0} is available."), updateResponse.NewVersion); - this.Snackbar.Add(message, Severity.Info, config => + switch (triggeredEvent) + { + case Event.UPDATE_AVAILABLE: + if (data is UpdateResponse updateResponse) { - config.Icon = Icons.Material.Filled.Update; - config.IconSize = Size.Large; - config.HideTransitionDuration = 600; - config.VisibleStateDuration = 32_000; - config.OnClick = async _ => + this.currentUpdateResponse = updateResponse; + var message = string.Format(T("An update to version {0} is available."), updateResponse.NewVersion); + this.Snackbar.Add(message, Severity.Info, config => { - await this.ShowUpdateDialog(); - }; - config.Action = T("Show details"); - config.ActionVariant = Variant.Filled; - }); - } - - break; - - case Event.CONFIGURATION_CHANGED: - if(this.SettingsManager.ConfigurationData.App.NavigationBehavior is NavBehavior.ALWAYS_EXPAND) - this.navBarOpen = true; - else - this.navBarOpen = false; - - await this.UpdateThemeConfiguration(); - this.LoadNavItems(); - this.StateHasChanged(); - break; - - case Event.COLOR_THEME_CHANGED: - this.StateHasChanged(); - break; - - case Event.SHOW_SUCCESS: - if (data is DataSuccessMessage success) - success.Show(this.Snackbar); - - break; - - case Event.SHOW_ERROR: - if (data is DataErrorMessage error) - error.Show(this.Snackbar); - - break; - - case Event.SHOW_WARNING: - if (data is DataWarningMessage warning) - warning.Show(this.Snackbar); - - break; - - case Event.STARTUP_PLUGIN_SYSTEM: - _ = Task.Run(async () => - { - // Set up the plugin system: - if (PluginFactory.Setup()) - { - // Ensure that all internal plugins are present: - await PluginFactory.EnsureInternalPlugins(); - - // Load (but not start) all plugins, without waiting for them: - var pluginLoadingTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - await PluginFactory.LoadAll(pluginLoadingTimeout.Token); - - // Set up hot reloading for plugins: - PluginFactory.SetUpHotReloading(); + config.Icon = Icons.Material.Filled.Update; + config.IconSize = Size.Large; + config.HideTransitionDuration = 600; + config.VisibleStateDuration = 32_000; + config.OnClick = async _ => + { + await this.ShowUpdateDialog(); + }; + config.Action = T("Show details"); + config.ActionVariant = Variant.Filled; + }); } - }); - break; - - case Event.PLUGINS_RELOADED: - this.Lang = await this.SettingsManager.GetActiveLanguagePlugin(); - I18N.Init(this.Lang); - this.LoadNavItems(); - - await this.InvokeAsync(this.StateHasChanged); - break; - } + + break; + + case Event.CONFIGURATION_CHANGED: + if (this.SettingsManager.ConfigurationData.App.NavigationBehavior is NavBehavior.ALWAYS_EXPAND) + this.navBarOpen = true; + else + this.navBarOpen = false; + + await this.UpdateThemeConfiguration(); + this.LoadNavItems(); + this.StateHasChanged(); + break; + + case Event.COLOR_THEME_CHANGED: + this.StateHasChanged(); + break; + + case Event.SHOW_SUCCESS: + if (data is DataSuccessMessage success) + success.Show(this.Snackbar); + + break; + + case Event.SHOW_ERROR: + if (data is DataErrorMessage error) + error.Show(this.Snackbar); + + break; + + case Event.SHOW_WARNING: + if (data is DataWarningMessage warning) + warning.Show(this.Snackbar); + + break; + + case Event.STARTUP_PLUGIN_SYSTEM: + _ = Task.Run(async () => + { + // Set up the plugin system: + if (PluginFactory.Setup()) + { + // Ensure that all internal plugins are present: + await PluginFactory.EnsureInternalPlugins(); + + // + // Check if there is an enterprise configuration plugin to download: + // + var enterpriseEnvironment = this.MessageBus.CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT).FirstOrDefault(); + if (enterpriseEnvironment != default) + await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl); + + // Load (but not start) all plugins without waiting for them: + var pluginLoadingTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await PluginFactory.LoadAll(pluginLoadingTimeout.Token); + + // Set up hot reloading for plugins: + PluginFactory.SetUpHotReloading(); + } + }); + break; + + case Event.PLUGINS_RELOADED: + this.Lang = await this.SettingsManager.GetActiveLanguagePlugin(); + I18N.Init(this.Lang); + this.LoadNavItems(); + + await this.InvokeAsync(this.StateHasChanged); + break; + } + }); } public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 85667ac9..072dd3ad 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -160,6 +160,7 @@ internal sealed class Program // Get the logging factory for e.g., static classes: LOGGER_FACTORY = app.Services.GetRequiredService(); + MessageBus.INSTANCE.Initialize(LOGGER_FACTORY.CreateLogger()); // Get a program logger: var programLogger = app.Services.GetRequiredService>(); diff --git a/app/MindWork AI Studio/Tools/EnterpriseEnvironment.cs b/app/MindWork AI Studio/Tools/EnterpriseEnvironment.cs index fd61b949..fd7e3fbe 100644 --- a/app/MindWork AI Studio/Tools/EnterpriseEnvironment.cs +++ b/app/MindWork AI Studio/Tools/EnterpriseEnvironment.cs @@ -1,6 +1,8 @@ +using System.Net.Http.Headers; + namespace AIStudio.Tools; -public readonly record struct EnterpriseEnvironment(string ConfigurationServerUrl, Guid ConfigurationId) +public readonly record struct EnterpriseEnvironment(string ConfigurationServerUrl, Guid ConfigurationId, EntityTagHeaderValue? ETag) { public bool IsActive => !string.IsNullOrEmpty(this.ConfigurationServerUrl) && this.ConfigurationId != Guid.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index 9741868d..fc8ca8e3 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -9,6 +9,7 @@ public enum Event CONFIGURATION_CHANGED, COLOR_THEME_CHANGED, STARTUP_PLUGIN_SYSTEM, + STARTUP_ENTERPRISE_ENVIRONMENT, PLUGINS_RELOADED, SHOW_ERROR, SHOW_WARNING, diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs index e37e54fc..6f27da87 100644 --- a/app/MindWork AI Studio/Tools/MessageBus.cs +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -15,9 +15,17 @@ public sealed class MessageBus private readonly ConcurrentQueue messageQueue = new(); private readonly SemaphoreSlim sendingSemaphore = new(1, 1); + private static ILogger? LOG; + private MessageBus() { } + + public void Initialize(ILogger logger) + { + LOG = logger; + LOG.LogInformation("Message bus initialized."); + } /// /// Define for which components and events you want to receive messages. @@ -48,7 +56,7 @@ public sealed class MessageBus public async Task SendMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data = default) { this.messageQueue.Enqueue(new Message(sendingComponent, triggeredEvent, data)); - + try { await this.sendingSemaphore.WaitAsync(); @@ -61,11 +69,16 @@ public sealed class MessageBus var eventFilter = this.componentEvents[receiver]; if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent)) + // We don't await the task here because we don't want to block the message bus: _ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data); } } } + catch (Exception e) + { + LOG?.LogError(e, "Error while sending message."); + } finally { this.sendingSemaphore.Release(); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index 30482b6e..ff4580c5 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -1,33 +1,61 @@ using System.IO.Compression; +using System.Net.Http.Headers; namespace AIStudio.Tools.PluginSystem; public static partial class PluginFactory { + public static async Task DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) + { + try + { + var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl; + var downloadUrl = $"{serverUrl}/{configPlugId}.zip"; + + using var http = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); + var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.Headers.ETag; + } + catch (Exception e) + { + LOG.LogError(e, "An error occurred while determining the ETag for the configuration plugin."); + return null; + } + } + public static async Task TryDownloadingConfigPluginAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) { - if (!IS_INITIALIZED) + if(!IS_INITIALIZED) + { + LOG.LogWarning("Plugin factory is not yet initialized. Cannot download configuration plugin."); return false; + } - LOG.LogInformation($"Downloading configuration plugin with ID: {configPlugId} from server: {configServerUrl}"); + var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl; + var downloadUrl = $"{serverUrl}/{configPlugId}.zip"; + + LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})"); var tempDownloadFile = Path.GetTempFileName(); try { using var httpClient = new HttpClient(); - var response = await httpClient.GetAsync($"{configServerUrl}/{configPlugId}.zip", cancellationToken); + var response = await httpClient.GetAsync(downloadUrl, cancellationToken); if (response.IsSuccessStatusCode) { - await using var tempFileStream = File.Create(tempDownloadFile); - await response.Content.CopyToAsync(tempFileStream, cancellationToken); + await using(var tempFileStream = File.Create(tempDownloadFile)) + { + await response.Content.CopyToAsync(tempFileStream, cancellationToken); + } - var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); - if(Directory.Exists(pluginDirectory)) - Directory.Delete(pluginDirectory, true); + var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); + if(Directory.Exists(configDirectory)) + Directory.Delete(configDirectory, true); - Directory.CreateDirectory(pluginDirectory); - ZipFile.ExtractToDirectory(tempDownloadFile, pluginDirectory); + Directory.CreateDirectory(configDirectory); + ZipFile.ExtractToDirectory(tempDownloadFile, configDirectory); - LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{pluginDirectory}'."); + LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'."); } else LOG.LogError($"Failed to download the enterprise configuration plugin. HTTP Status: {response.StatusCode}"); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs index 6cd8eb55..7bed742e 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs @@ -46,6 +46,9 @@ public static partial class PluginFactory try { LOG.LogInformation($"File changed ({changeType}): {args.FullPath}. Reloading plugins..."); + + // Wait for parallel writes to finish: + await Task.Delay(TimeSpan.FromSeconds(3)); await LoadAll(); await MessageBus.INSTANCE.SendMessage(null, Event.PLUGINS_RELOADED); } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs index a5aaef37..352c9bb3 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -27,6 +27,7 @@ public static partial class PluginFactory if(IS_INITIALIZED) return false; + LOG.LogInformation("Initializing plugin factory..."); DATA_DIR = SettingsManager.DataDirectory!; PLUGINS_ROOT = Path.Join(DATA_DIR, "plugins"); INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal"); @@ -37,7 +38,7 @@ public static partial class PluginFactory HOT_RELOAD_WATCHER = new(PLUGINS_ROOT); IS_INITIALIZED = true; - + LOG.LogInformation("Plugin factory initialized successfully."); return true; } diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index b0b480ec..ac41b4ba 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -2,11 +2,15 @@ using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.Services; -public sealed class EnterpriseEnvironmentService(ILogger logger, RustService rustService) : BackgroundService +public sealed class EnterpriseEnvironmentService(ILogger logger, RustService rustService) : BackgroundService { public static EnterpriseEnvironment CURRENT_ENVIRONMENT; - + +#if DEBUG + private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6); +#else private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(16); +#endif #region Overrides of BackgroundService @@ -14,7 +18,7 @@ public sealed class EnterpriseEnvironmentService(ILogger l { logger.LogInformation("The enterprise environment service was initialized."); - await this.StartUpdating(); + await this.StartUpdating(isFirstRun: true); while (!stoppingToken.IsCancellationRequested) { await Task.Delay(CHECK_INTERVAL, stoppingToken); @@ -24,7 +28,7 @@ public sealed class EnterpriseEnvironmentService(ILogger l #endregion - private async Task StartUpdating() + private async Task StartUpdating(bool isFirstRun = false) { try { @@ -40,7 +44,8 @@ public sealed class EnterpriseEnvironmentService(ILogger l var enterpriseConfigServerUrl = await rustService.EnterpriseEnvConfigServerUrl(); var enterpriseConfigId = await rustService.EnterpriseEnvConfigId(); - var nextEnterpriseEnvironment = new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId); + var etag = await PluginFactory.DetermineConfigPluginETagAsync(enterpriseConfigId, enterpriseConfigServerUrl); + var nextEnterpriseEnvironment = new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag); if (CURRENT_ENVIRONMENT != nextEnterpriseEnvironment) { logger.LogInformation("The enterprise environment has changed. Updating the current environment."); @@ -62,10 +67,16 @@ public sealed class EnterpriseEnvironmentService(ILogger l default: logger.LogInformation($"AI Studio runs with an enterprise configuration id ('{enterpriseConfigId}') and configuration server URL ('{enterpriseConfigServerUrl}')."); - await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseConfigId, enterpriseConfigServerUrl); + + if(isFirstRun) + MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag)); + else + await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseConfigId, enterpriseConfigServerUrl); break; } } + else + logger.LogInformation("The enterprise environment has not changed. No update required."); } catch (Exception e) { diff --git a/app/MindWork AI Studio/wwwroot/changelog/v0.9.47.md b/app/MindWork AI Studio/wwwroot/changelog/v0.9.47.md index bb847009..94300268 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v0.9.47.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v0.9.47.md @@ -1 +1,2 @@ # v0.9.47, build 222 (2025-06-xx xx:xx UTC) +- We improved AI Studio for managed enterprise environments by refactoring the remote config server handling, the config plugin loading mechanism, and we improved the overall resilience. \ No newline at end of file diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 06c9447c..b62980a9 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -64,6 +64,7 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String { // The environment variable is: // MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID // + info!("Trying to read the enterprise environment for some config ID."); get_enterprise_configuration( "config_id", "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID", @@ -87,6 +88,7 @@ pub fn delete_enterprise_env_config_id(_token: APIToken) -> String { // The environment variable is: // MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID // + info!("Trying to read the enterprise environment for some config ID, which should be deleted."); get_enterprise_configuration( "delete_config_id", "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID", @@ -110,6 +112,7 @@ pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { // The environment variable is: // MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL // + info!("Trying to read the enterprise environment for the config server URL."); get_enterprise_configuration( "config_server_url", "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL", @@ -117,7 +120,6 @@ pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { } fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { - info!("Trying to read the enterprise environment for some predefined configuration."); cfg_if::cfg_if! { if #[cfg(target_os = "windows")] { info!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT' or environment variables."); @@ -133,7 +135,7 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { val }, Err(_) => { - info!("Falling back to the environment variable '{}' was not successful. It appears that this is not an enterprise environment.", env_name); + info!("Falling back to the environment variable '{}' was not successful.", env_name); "".to_string() }, } @@ -150,7 +152,7 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { val }, Err(_) => { - info!("Falling back to the environment variable '{}' was not successful. It appears that this is not an enterprise environment.", env_name); + info!("Falling back to the environment variable '{}' was not successful.", env_name); "".to_string() } } @@ -162,7 +164,7 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { match env::var(env_name) { Ok(val) => val, Err(_) => { - info!("The environment variable '{}' was not found. It appears that this is not an enterprise environment.", env_name); + info!("The environment variable '{}' was not found.", env_name); "".to_string() } }