diff --git a/DiunaBI.API/Hubs/EntityChangeHub.cs b/DiunaBI.API/Hubs/EntityChangeHub.cs new file mode 100644 index 0000000..e694ee9 --- /dev/null +++ b/DiunaBI.API/Hubs/EntityChangeHub.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.SignalR; + +namespace DiunaBI.API.Hubs; + +public class EntityChangeHub : Hub +{ + public async Task SendEntityChange(string module, string id, string operation) + { + await Clients.All.SendAsync("EntityChanged", new + { + module, + id, + operation + }); + } +} diff --git a/DiunaBI.API/Program.cs b/DiunaBI.API/Program.cs index cef38b5..0e08ff8 100644 --- a/DiunaBI.API/Program.cs +++ b/DiunaBI.API/Program.cs @@ -4,8 +4,10 @@ using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Reflection; using System.Text; +using DiunaBI.API.Hubs; using DiunaBI.API.Services; using DiunaBI.Infrastructure.Data; +using DiunaBI.Infrastructure.Interceptors; using DiunaBI.Infrastructure.Services; using Google.Apis.Sheets.v4; using Serilog; @@ -29,10 +31,17 @@ if (builder.Environment.IsProduction()) var connectionString = builder.Configuration.GetConnectionString("SQLDatabase"); -builder.Services.AddDbContext(x => +// Register EntityChangeInterceptor +builder.Services.AddSingleton(); + +builder.Services.AddDbContext((serviceProvider, options) => { - x.UseSqlServer(connectionString, sqlOptions => sqlOptions.MigrationsAssembly("DiunaBI.Infrastructure")); - x.EnableSensitiveDataLogging(); + options.UseSqlServer(connectionString, sqlOptions => sqlOptions.MigrationsAssembly("DiunaBI.Infrastructure")); + options.EnableSensitiveDataLogging(); + + // Add EntityChangeInterceptor + var interceptor = serviceProvider.GetRequiredService(); + options.AddInterceptors(interceptor); }); builder.Services.AddCors(options => @@ -58,6 +67,9 @@ builder.Services.AddCors(options => builder.Services.AddControllers(); +// SignalR +builder.Services.AddSignalR(); + builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -244,6 +256,9 @@ app.Use(async (context, next) => app.MapControllers(); +// SignalR Hub +app.MapHub("/hubs/entitychanges"); + app.MapGet("/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow })) .AllowAnonymous(); diff --git a/DiunaBI.Infrastructure/DiunaBI.Infrastructure.csproj b/DiunaBI.Infrastructure/DiunaBI.Infrastructure.csproj index e4041a6..fee4d4f 100644 --- a/DiunaBI.Infrastructure/DiunaBI.Infrastructure.csproj +++ b/DiunaBI.Infrastructure/DiunaBI.Infrastructure.csproj @@ -26,4 +26,8 @@ + + + + \ No newline at end of file diff --git a/DiunaBI.Infrastructure/Interceptors/EntityChangeInterceptor.cs b/DiunaBI.Infrastructure/Interceptors/EntityChangeInterceptor.cs new file mode 100644 index 0000000..490581e --- /dev/null +++ b/DiunaBI.Infrastructure/Interceptors/EntityChangeInterceptor.cs @@ -0,0 +1,201 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace DiunaBI.Infrastructure.Interceptors; + +public class EntityChangeInterceptor : SaveChangesInterceptor +{ + private readonly object? _hubContext; + private readonly ILogger? _logger; + private readonly List<(string Module, string Id, string Operation)> _pendingChanges = new(); + + public EntityChangeInterceptor(IServiceProvider serviceProvider) + { + _logger = serviceProvider.GetService(typeof(ILogger)) as ILogger; + + // Try to get hub context - it may not be registered in some scenarios (e.g., migrations) + try + { + var hubType = Type.GetType("DiunaBI.API.Hubs.EntityChangeHub, DiunaBI.API"); + if (hubType != null) + { + var hubContextType = typeof(IHubContext<>).MakeGenericType(hubType); + _hubContext = serviceProvider.GetService(hubContextType); + + if (_hubContext != null) + { + _logger?.LogInformation("✅ EntityChangeInterceptor: Hub context initialized"); + Console.WriteLine("✅ EntityChangeInterceptor: Hub context initialized"); + } + else + { + _logger?.LogWarning("⚠️ EntityChangeInterceptor: Hub context is null"); + Console.WriteLine("⚠️ EntityChangeInterceptor: Hub context is null"); + } + } + else + { + _logger?.LogWarning("⚠️ EntityChangeInterceptor: Hub type not found"); + Console.WriteLine("⚠️ EntityChangeInterceptor: Hub type not found"); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "❌ EntityChangeInterceptor: Failed to initialize hub context"); + Console.WriteLine($"❌ EntityChangeInterceptor: Failed to initialize hub context: {ex.Message}"); + _hubContext = null; + } + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + _pendingChanges.Clear(); + + Console.WriteLine($"🔍 EntityChangeInterceptor.SavingChangesAsync called. HubContext null? {_hubContext == null}, Context null? {eventData.Context == null}"); + + if (_hubContext != null && eventData.Context != null) + { + // Capture changes BEFORE save + var entries = eventData.Context.ChangeTracker.Entries().ToList(); + Console.WriteLine($"🔍 Found {entries.Count} total entries in ChangeTracker"); + + foreach (var entry in entries) + { + Console.WriteLine($"🔍 Entry: {entry.Metadata.ClrType.Name}, State: {entry.State}"); + + if (entry.State == EntityState.Added || + entry.State == EntityState.Modified || + entry.State == EntityState.Deleted) + { + var module = entry.Metadata.GetTableName() ?? entry.Metadata.ClrType.Name; + var id = GetEntityId(entry); + var operation = entry.State switch + { + EntityState.Added => "created", + EntityState.Modified => "updated", + EntityState.Deleted => "deleted", + _ => "unknown" + }; + + Console.WriteLine($"🔍 Detected change: {module} {id} {operation}"); + + if (id != null) + { + _pendingChanges.Add((module, id, operation)); + Console.WriteLine($"✅ Added to pending changes: {module} {id} {operation}"); + } + else + { + Console.WriteLine($"⚠️ Skipped (id is null): {module} {operation}"); + } + } + } + + Console.WriteLine($"🔍 Total pending changes: {_pendingChanges.Count}"); + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public override async ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + // Broadcast changes AFTER successful save + if (_hubContext != null && result > 0 && _pendingChanges.Any()) + { + _logger?.LogInformation("📤 Broadcasting {Count} entity changes via SignalR", _pendingChanges.Count); + Console.WriteLine($"📤 Broadcasting {_pendingChanges.Count} entity changes via SignalR"); + + foreach (var (module, id, operation) in _pendingChanges) + { + try + { + Console.WriteLine($"📤 Broadcasting: {module} {id} {operation}"); + + // Use reflection to call hub methods since we can't reference the API project + var clientsProperty = _hubContext.GetType().GetProperty("Clients"); + Console.WriteLine($" 🔍 Clients property: {clientsProperty != null}"); + + if (clientsProperty != null) + { + var clients = clientsProperty.GetValue(_hubContext); + Console.WriteLine($" 🔍 Clients value: {clients != null}, Type: {clients?.GetType().Name}"); + + if (clients != null) + { + var allProperty = clients.GetType().GetProperty("All"); + Console.WriteLine($" 🔍 All property: {allProperty != null}"); + + if (allProperty != null) + { + var allClients = allProperty.GetValue(clients); + Console.WriteLine($" 🔍 AllClients value: {allClients != null}, Type: {allClients?.GetType().Name}"); + + if (allClients != null) + { + // SendAsync is an extension method, so we need to find it differently + // Look for the IClientProxy interface which has SendCoreAsync + var sendCoreAsyncMethod = allClients.GetType().GetMethod("SendCoreAsync"); + Console.WriteLine($" 🔍 SendCoreAsync method found: {sendCoreAsyncMethod != null}"); + + if (sendCoreAsyncMethod != null) + { + // SendCoreAsync takes (string method, object?[] args, CancellationToken cancellationToken) + var task = sendCoreAsyncMethod.Invoke(allClients, new object[] + { + "EntityChanged", + new object[] { new { module, id, operation } }, + cancellationToken + }) as Task; + + Console.WriteLine($" 🔍 Task created: {task != null}"); + + if (task != null) + { + await task; + Console.WriteLine($"✅ Broadcast successful: {module} {id} {operation}"); + } + else + { + Console.WriteLine($"❌ Task is null after invoke"); + } + } + else + { + Console.WriteLine($"❌ SendCoreAsync method not found"); + } + } + } + } + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "❌ Failed to broadcast entity change"); + Console.WriteLine($"❌ Failed to broadcast: {ex.Message}"); + Console.WriteLine($"❌ Stack trace: {ex.StackTrace}"); + } + } + } + + _pendingChanges.Clear(); + return await base.SavedChangesAsync(eventData, result, cancellationToken); + } + + private static string? GetEntityId(Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry) + { + var keyProperty = entry.Metadata.FindPrimaryKey()?.Properties.FirstOrDefault(); + if (keyProperty == null) + return null; + + var value = entry.Property(keyProperty.Name).CurrentValue; + return value?.ToString(); + } +} diff --git a/DiunaBI.UI.Shared/Components/JobListComponent.razor.cs b/DiunaBI.UI.Shared/Components/JobListComponent.razor.cs index a1bb1ea..0ffdd6d 100644 --- a/DiunaBI.UI.Shared/Components/JobListComponent.razor.cs +++ b/DiunaBI.UI.Shared/Components/JobListComponent.razor.cs @@ -8,9 +8,10 @@ using Microsoft.JSInterop; namespace DiunaBI.UI.Shared.Components; -public partial class JobListComponent : ComponentBase +public partial class JobListComponent : ComponentBase, IDisposable { [Inject] private JobService JobService { get; set; } = default!; + [Inject] private EntityChangeHubService HubService { get; set; } = default!; [Inject] private ISnackbar Snackbar { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private IJSRuntime JSRuntime { get; set; } = default!; @@ -25,6 +26,32 @@ public partial class JobListComponent : ComponentBase protected override async Task OnInitializedAsync() { await LoadJobs(); + + // Subscribe to SignalR entity changes + HubService.EntityChanged += OnEntityChanged; + } + + private async void OnEntityChanged(string module, string id, string operation) + { + Console.WriteLine($"🔔 JobListComponent.OnEntityChanged called: module={module}, id={id}, operation={operation}"); + + // Only react if it's a QueueJobs change + if (module.Equals("QueueJobs", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"📨 Job {id} changed, refreshing job list"); + await InvokeAsync(async () => + { + Console.WriteLine($"🔄 LoadJobs starting..."); + await LoadJobs(); + Console.WriteLine($"🔄 StateHasChanged calling..."); + StateHasChanged(); + Console.WriteLine($"✅ Job list refresh complete"); + }); + } + else + { + Console.WriteLine($"⏭️ Skipping - module '{module}' is not QueueJobs"); + } } private async Task LoadJobs() @@ -107,4 +134,9 @@ public partial class JobListComponent : ComponentBase _ => Color.Default }; } + + public void Dispose() + { + HubService.EntityChanged -= OnEntityChanged; + } } diff --git a/DiunaBI.UI.Shared/DiunaBI.UI.Shared.csproj b/DiunaBI.UI.Shared/DiunaBI.UI.Shared.csproj index f65b4fe..7c9079e 100644 --- a/DiunaBI.UI.Shared/DiunaBI.UI.Shared.csproj +++ b/DiunaBI.UI.Shared/DiunaBI.UI.Shared.csproj @@ -17,6 +17,7 @@ + diff --git a/DiunaBI.UI.Shared/Extensions/ServiceCollectionExtensions.cs b/DiunaBI.UI.Shared/Extensions/ServiceCollectionExtensions.cs index 9dd942c..9ecb8a1 100644 --- a/DiunaBI.UI.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/DiunaBI.UI.Shared/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using DiunaBI.UI.Shared.Services; using DiunaBI.UI.Shared.Handlers; @@ -44,6 +46,15 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // SignalR Hub Service (singleton for global connection shared across all users) + services.AddSingleton(sp => + { + // For singleton, we can't inject scoped services directly + // We'll get them from the service provider when needed + var logger = sp.GetRequiredService>(); + return new EntityChangeHubService(apiBaseUrl, sp, logger); + }); + return services; } } \ No newline at end of file diff --git a/DiunaBI.UI.Shared/MainLayout.razor b/DiunaBI.UI.Shared/MainLayout.razor index 793dacc..3f1bab8 100644 --- a/DiunaBI.UI.Shared/MainLayout.razor +++ b/DiunaBI.UI.Shared/MainLayout.razor @@ -1,6 +1,7 @@ @using MudBlazor @using DiunaBI.UI.Shared.Services @inject AppConfig AppConfig +@inject EntityChangeHubService HubService @inherits LayoutComponentBase @@ -54,6 +55,12 @@ private bool _drawerOpen = true; private DrawerVariant _drawerVariant = DrawerVariant.Persistent; + protected override async Task OnInitializedAsync() + { + // Initialize SignalR connection when layout loads + await HubService.InitializeAsync(); + } + private MudTheme _theme = new MudTheme() { PaletteLight = new PaletteLight() diff --git a/DiunaBI.UI.Shared/Pages/JobDetailPage.razor b/DiunaBI.UI.Shared/Pages/JobDetailPage.razor index 779c55a..4574e4a 100644 --- a/DiunaBI.UI.Shared/Pages/JobDetailPage.razor +++ b/DiunaBI.UI.Shared/Pages/JobDetailPage.razor @@ -3,8 +3,10 @@ @using DiunaBI.Domain.Entities @using MudBlazor @inject JobService JobService +@inject EntityChangeHubService HubService @inject NavigationManager NavigationManager @inject ISnackbar Snackbar +@implements IDisposable @@ -160,6 +162,24 @@ protected override async Task OnInitializedAsync() { await LoadJob(); + + // Subscribe to SignalR entity changes + HubService.EntityChanged += OnEntityChanged; + } + + private async void OnEntityChanged(string module, string id, string operation) + { + // Only react if it's a QueueJobs change for this specific job + if (module.Equals("QueueJobs", StringComparison.OrdinalIgnoreCase) && + Guid.TryParse(id, out var jobId) && jobId == Id) + { + Console.WriteLine($"📨 Job {jobId} changed, refreshing detail page"); + await InvokeAsync(async () => + { + await LoadJob(); + StateHasChanged(); + }); + } } private async Task LoadJob() @@ -242,4 +262,9 @@ _ => Icons.Material.Filled.Help }; } + + public void Dispose() + { + HubService.EntityChanged -= OnEntityChanged; + } } diff --git a/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs b/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs index 361f59b..ceafab4 100644 --- a/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs +++ b/DiunaBI.UI.Shared/Pages/LayerDetailPage.razor.cs @@ -439,10 +439,10 @@ public partial class LayerDetailPage : ComponentBase } else { - Snackbar.Add("Job created successfully!", Severity.Success); + Snackbar.Add("Job created successfully! Watch real-time status updates.", Severity.Success); } - // Navigate to job detail page + // Navigate to job detail page to see real-time updates NavigationManager.NavigateTo($"/jobs/{result.JobId}"); } else diff --git a/DiunaBI.UI.Shared/Services/EntityChangeHubService.cs b/DiunaBI.UI.Shared/Services/EntityChangeHubService.cs new file mode 100644 index 0000000..ea671f8 --- /dev/null +++ b/DiunaBI.UI.Shared/Services/EntityChangeHubService.cs @@ -0,0 +1,191 @@ +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; + +namespace DiunaBI.UI.Shared.Services; + +public class EntityChangeHubService : IAsyncDisposable +{ + private readonly string _hubUrl; + private readonly ILogger _logger; + private HubConnection? _hubConnection; + private bool _isInitialized; + private readonly SemaphoreSlim _initializationLock = new SemaphoreSlim(1, 1); + private static int _instanceCounter = 0; + private readonly int _instanceId; + + // Events that components can subscribe to + public event Action? EntityChanged; + + public EntityChangeHubService( + string apiBaseUrl, + IServiceProvider serviceProvider, + ILogger logger) + { + _instanceId = Interlocked.Increment(ref _instanceCounter); + + // Convert HTTP URL to SignalR hub URL + var baseUrl = apiBaseUrl.TrimEnd('/'); + _hubUrl = baseUrl + "/hubs/entitychanges"; + + _logger = logger; + _logger.LogInformation("🏗️ EntityChangeHubService instance #{InstanceId} created. Hub URL: {HubUrl}", _instanceId, _hubUrl); + Console.WriteLine($"🏗️ EntityChangeHubService instance #{_instanceId} created. Hub URL: {_hubUrl}, _isInitialized = {_isInitialized}"); + } + + public async Task InitializeAsync() + { + _logger.LogInformation("🔍 Instance #{InstanceId} InitializeAsync called. _isInitialized = {IsInitialized}, _hubConnection null? {IsNull}", _instanceId, _isInitialized, _hubConnection == null); + Console.WriteLine($"🔍 Instance #{_instanceId} InitializeAsync called. _isInitialized = {_isInitialized}, _hubConnection null? {_hubConnection == null}"); + + if (_isInitialized) + { + _logger.LogInformation("⏭️ Instance #{InstanceId} SignalR already initialized, skipping", _instanceId); + Console.WriteLine($"⏭️ Instance #{_instanceId} SignalR already initialized, skipping"); + return; + } + + await _initializationLock.WaitAsync(); + try + { + // Double-check after acquiring lock + if (_isInitialized) + { + Console.WriteLine($"⏭️ SignalR already initialized (after lock), skipping"); + return; + } + + _logger.LogInformation("🔌 Initializing SignalR connection to {HubUrl}", _hubUrl); + Console.WriteLine($"🔌 Initializing SignalR connection to {_hubUrl}"); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(_hubUrl) + .WithAutomaticReconnect() + .Build(); + + // Subscribe to EntityChanged messages + _hubConnection.On("EntityChanged", (data) => + { + Console.WriteLine($"🔔 RAW SignalR message received at {DateTime.Now:HH:mm:ss.fff}"); + Console.WriteLine($"🔔 Data type: {data?.GetType().FullName}"); + + try + { + // Parse the anonymous object + var json = System.Text.Json.JsonSerializer.Serialize(data); + Console.WriteLine($"📨 Received SignalR message: {json}"); + + // Use case-insensitive deserialization (backend sends camelCase: module, id, operation) + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + var change = System.Text.Json.JsonSerializer.Deserialize(json, options); + + if (change != null) + { + _logger.LogInformation("📨 Received entity change: {Module} {Id} {Operation}", + change.Module, change.Id, change.Operation); + Console.WriteLine($"📨 Entity change: {change.Module} {change.Id} {change.Operation}"); + + // Notify all subscribers + Console.WriteLine($"🔔 Invoking EntityChanged event, subscribers: {EntityChanged?.GetInvocationList().Length ?? 0}"); + EntityChanged?.Invoke(change.Module, change.Id, change.Operation); + Console.WriteLine($"🔔 EntityChanged event invoked successfully"); + } + else + { + Console.WriteLine($"⚠️ Deserialized change is null"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Error processing entity change message"); + Console.WriteLine($"❌ Error processing message: {ex.Message}"); + Console.WriteLine($"❌ Stack trace: {ex.StackTrace}"); + } + }); + + _hubConnection.Reconnecting += (error) => + { + _logger.LogWarning("SignalR reconnecting: {Error}", error?.Message); + Console.WriteLine($"⚠️ SignalR reconnecting: {error?.Message}"); + return Task.CompletedTask; + }; + + _hubConnection.Reconnected += (connectionId) => + { + _logger.LogInformation("✅ SignalR reconnected: {ConnectionId}", connectionId); + Console.WriteLine($"✅ SignalR reconnected: {connectionId}"); + return Task.CompletedTask; + }; + + _hubConnection.Closed += (error) => + { + _logger.LogError(error, "❌ SignalR connection closed"); + Console.WriteLine($"❌ SignalR connection closed: {error?.Message}"); + return Task.CompletedTask; + }; + + await StartConnectionAsync(); + _isInitialized = true; + _logger.LogInformation("✅ Instance #{InstanceId} _isInitialized set to true", _instanceId); + Console.WriteLine($"✅ Instance #{_instanceId} _isInitialized set to true"); + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Instance #{InstanceId} Failed to initialize SignalR connection", _instanceId); + Console.WriteLine($"❌ Instance #{_instanceId} Failed to initialize SignalR: {ex.Message}"); + } + finally + { + _initializationLock.Release(); + } + } + + private async Task StartConnectionAsync() + { + if (_hubConnection == null) + { + _logger.LogWarning("Hub connection is null, cannot start"); + return; + } + + try + { + Console.WriteLine($"🔌 Starting SignalR connection..."); + await _hubConnection.StartAsync(); + _logger.LogInformation("✅ SignalR connected successfully"); + Console.WriteLine($"✅ SignalR connected successfully to {_hubUrl}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Failed to start SignalR connection"); + Console.WriteLine($"❌ Failed to start SignalR: {ex.Message}\n{ex.StackTrace}"); + } + } + + public async ValueTask DisposeAsync() + { + if (_hubConnection != null) + { + try + { + await _hubConnection.StopAsync(); + await _hubConnection.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disposing SignalR connection"); + } + } + + _initializationLock?.Dispose(); + } +} + +public class EntityChangeMessage +{ + public string Module { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string Operation { get; set; } = string.Empty; +}