From 013088a25f54305f5063d2095bc8d6caf94da6ac Mon Sep 17 00:00:00 2001 From: OlgunR Date: Wed, 4 Feb 2026 09:01:28 +0100 Subject: [PATCH] Add real-time dashboard updates with SignalR Integrate SignalR to provide real-time dashboard update notifications. - Added DashboardsHub and DashboardChangeNotifier on the backend. - Modified SqlDashboardStorage to trigger notifications on changes. - Registered SignalR services and mapped the hub endpoint. - Updated Blazor clients to connect to the hub and refresh dashboards on change. - Added SignalR client packages and necessary DI/configuration. --- .../Dashboards/DashboardChangeNotifier.cs | 19 ++++++++ .../Dashboards/IDashboardChangeNotifier.cs | 6 +++ DbFirst.API/Dashboards/SqlDashboardStorage.cs | 8 +++- DbFirst.API/Hubs/DashboardsHub.cs | 7 +++ DbFirst.API/Program.cs | 8 +++- DbFirst.BlazorWasm/DbFirst.BlazorWasm.csproj | 1 + DbFirst.BlazorWasm/Pages/Dashboard.razor | 45 ++++++++++++++++++- DbFirst.BlazorWasm/_Imports.razor | 1 + .../Components/Pages/Dashboard.razor | 45 ++++++++++++++++++- .../Components/_Imports.razor | 1 + .../DbFirst.BlazorWebApp.csproj | 4 ++ 11 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 DbFirst.API/Dashboards/DashboardChangeNotifier.cs create mode 100644 DbFirst.API/Dashboards/IDashboardChangeNotifier.cs create mode 100644 DbFirst.API/Hubs/DashboardsHub.cs diff --git a/DbFirst.API/Dashboards/DashboardChangeNotifier.cs b/DbFirst.API/Dashboards/DashboardChangeNotifier.cs new file mode 100644 index 0000000..eb5f049 --- /dev/null +++ b/DbFirst.API/Dashboards/DashboardChangeNotifier.cs @@ -0,0 +1,19 @@ +using DbFirst.API.Hubs; +using Microsoft.AspNetCore.SignalR; + +namespace DbFirst.API.Dashboards; + +public class DashboardChangeNotifier : IDashboardChangeNotifier +{ + private readonly IHubContext _hubContext; + + public DashboardChangeNotifier(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public void NotifyChanged() + { + _ = _hubContext.Clients.All.SendAsync("DashboardsChanged"); + } +} diff --git a/DbFirst.API/Dashboards/IDashboardChangeNotifier.cs b/DbFirst.API/Dashboards/IDashboardChangeNotifier.cs new file mode 100644 index 0000000..94ddee5 --- /dev/null +++ b/DbFirst.API/Dashboards/IDashboardChangeNotifier.cs @@ -0,0 +1,6 @@ +namespace DbFirst.API.Dashboards; + +public interface IDashboardChangeNotifier +{ + void NotifyChanged(); +} diff --git a/DbFirst.API/Dashboards/SqlDashboardStorage.cs b/DbFirst.API/Dashboards/SqlDashboardStorage.cs index fd44eda..263cb60 100644 --- a/DbFirst.API/Dashboards/SqlDashboardStorage.cs +++ b/DbFirst.API/Dashboards/SqlDashboardStorage.cs @@ -11,12 +11,14 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage private readonly string _connectionString; private readonly string _tableName; private readonly Func? _userProvider; + private readonly IDashboardChangeNotifier? _notifier; - public SqlDashboardStorage(string connectionString, string tableName = "TBDD_SMF_CONFIG", Func? userProvider = null) + public SqlDashboardStorage(string connectionString, string tableName = "TBDD_SMF_CONFIG", Func? userProvider = null, IDashboardChangeNotifier? notifier = null) { _connectionString = connectionString; _tableName = tableName; _userProvider = userProvider; + _notifier = notifier; } public IEnumerable GetAvailableDashboardsInfo() @@ -98,6 +100,7 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage connection.Open(); command.ExecuteNonQuery(); + _notifier?.NotifyChanged(); return id; } @@ -118,6 +121,8 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage { throw new ArgumentException($"Dashboard '{dashboardId}' not found."); } + + _notifier?.NotifyChanged(); } public void DeleteDashboard(string dashboardId) @@ -128,5 +133,6 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage connection.Open(); command.ExecuteNonQuery(); + _notifier?.NotifyChanged(); } } diff --git a/DbFirst.API/Hubs/DashboardsHub.cs b/DbFirst.API/Hubs/DashboardsHub.cs new file mode 100644 index 0000000..92c164c --- /dev/null +++ b/DbFirst.API/Hubs/DashboardsHub.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.SignalR; + +namespace DbFirst.API.Hubs; + +public class DashboardsHub : Hub +{ +} diff --git a/DbFirst.API/Program.cs b/DbFirst.API/Program.cs index a3405d2..58e1536 100644 --- a/DbFirst.API/Program.cs +++ b/DbFirst.API/Program.cs @@ -1,5 +1,6 @@ using DbFirst.API.Middleware; using DbFirst.API.Dashboards; +using DbFirst.API.Hubs; using DbFirst.Application; using DbFirst.Application.Repositories; using DbFirst.Domain; @@ -53,6 +54,8 @@ builder.Services.AddApplication(); builder.Services.AddScoped(); builder.Services.AddDevExpressControls(); +builder.Services.AddSignalR(); +builder.Services.AddSingleton(); builder.Services.AddScoped((IServiceProvider serviceProvider) => { var dashboardsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "Dashboards"); Directory.CreateDirectory(dashboardsPath); @@ -112,7 +115,8 @@ builder.Services.AddScoped((IServiceProvider serviceProvi DashboardConfigurator configurator = new DashboardConfigurator(); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? string.Empty; - var dashboardStorage = new SqlDashboardStorage(connectionString, "TBDD_SMF_CONFIG"); + var notifier = serviceProvider.GetRequiredService(); + var dashboardStorage = new SqlDashboardStorage(connectionString, "TBDD_SMF_CONFIG", notifier: notifier); configurator.SetDashboardStorage(dashboardStorage); DataSourceInMemoryStorage dataSourceStorage = new DataSourceInMemoryStorage(); @@ -155,7 +159,7 @@ app.UseCors(); app.UseAuthorization(); app.MapDashboardRoute("api/dashboard", "DefaultDashboard"); - +app.MapHub("/hubs/dashboards"); app.MapControllers(); app.Run(); diff --git a/DbFirst.BlazorWasm/DbFirst.BlazorWasm.csproj b/DbFirst.BlazorWasm/DbFirst.BlazorWasm.csproj index cdcb88f..d29b943 100644 --- a/DbFirst.BlazorWasm/DbFirst.BlazorWasm.csproj +++ b/DbFirst.BlazorWasm/DbFirst.BlazorWasm.csproj @@ -13,6 +13,7 @@ + diff --git a/DbFirst.BlazorWasm/Pages/Dashboard.razor b/DbFirst.BlazorWasm/Pages/Dashboard.razor index 73758b6..9e5daea 100644 --- a/DbFirst.BlazorWasm/Pages/Dashboard.razor +++ b/DbFirst.BlazorWasm/Pages/Dashboard.razor @@ -1,5 +1,6 @@ @page "/dashboard" @page "/dashboards/{DashboardId?}" +@implements IAsyncDisposable @inject Microsoft.Extensions.Configuration.IConfiguration Configuration @inject NavigationManager Navigation @inject DashboardApiClient DashboardApi @@ -77,19 +78,38 @@ [SupplyParameterFromQuery] public string? Mode { get; set; } private readonly List dashboards = new(); + private HubConnection? _hubConnection; private bool IsDesigner => !string.Equals(Mode, "viewer", StringComparison.OrdinalIgnoreCase); private WorkingMode CurrentMode => IsDesigner ? WorkingMode.Designer : WorkingMode.ViewerOnly; - private string SelectedDashboardId { get; set; } = ""; + private string SelectedDashboardId { get; set; } = string.Empty; private string DashboardKey => $"{SelectedDashboardId}-{(IsDesigner ? "designer" : "viewer")}"; private string DashboardEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/api/dashboard"; + private string HubEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/hubs/dashboards"; + + protected override async Task OnInitializedAsync() + { + await RefreshDashboards(); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(HubEndpoint) + .WithAutomaticReconnect() + .Build(); + + _hubConnection.On("DashboardsChanged", async () => + { + await RefreshDashboards(); + }); + + await _hubConnection.StartAsync(); + } protected override async Task OnParametersSetAsync() { if (dashboards.Count == 0) { - dashboards.AddRange(await DashboardApi.GetAllAsync()); + await RefreshDashboards(); } var requestedId = string.IsNullOrWhiteSpace(DashboardId) || string.Equals(DashboardId, "default", StringComparison.OrdinalIgnoreCase) @@ -119,4 +139,25 @@ var targetMode = IsDesigner ? "viewer" : "designer"; Navigation.NavigateTo($"dashboards/{SelectedDashboardId}?mode={targetMode}", replace: true); } + + private async Task RefreshDashboards() + { + var latest = await DashboardApi.GetAllAsync(); + if (latest.Count == dashboards.Count && latest.All(d => dashboards.Any(x => x.Id == d.Id && x.Name == d.Name))) + { + return; + } + + dashboards.Clear(); + dashboards.AddRange(latest); + await InvokeAsync(StateHasChanged); + } + + public async ValueTask DisposeAsync() + { + if (_hubConnection != null) + { + await _hubConnection.DisposeAsync(); + } + } } \ No newline at end of file diff --git a/DbFirst.BlazorWasm/_Imports.razor b/DbFirst.BlazorWasm/_Imports.razor index 519e801..1997061 100644 --- a/DbFirst.BlazorWasm/_Imports.razor +++ b/DbFirst.BlazorWasm/_Imports.razor @@ -6,6 +6,7 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop +@using Microsoft.AspNetCore.SignalR.Client @using DbFirst.BlazorWasm @using DbFirst.BlazorWasm.Layout @using DbFirst.BlazorWasm.Models diff --git a/DbFirst.BlazorWebApp/Components/Pages/Dashboard.razor b/DbFirst.BlazorWebApp/Components/Pages/Dashboard.razor index 73758b6..9e5daea 100644 --- a/DbFirst.BlazorWebApp/Components/Pages/Dashboard.razor +++ b/DbFirst.BlazorWebApp/Components/Pages/Dashboard.razor @@ -1,5 +1,6 @@ @page "/dashboard" @page "/dashboards/{DashboardId?}" +@implements IAsyncDisposable @inject Microsoft.Extensions.Configuration.IConfiguration Configuration @inject NavigationManager Navigation @inject DashboardApiClient DashboardApi @@ -77,19 +78,38 @@ [SupplyParameterFromQuery] public string? Mode { get; set; } private readonly List dashboards = new(); + private HubConnection? _hubConnection; private bool IsDesigner => !string.Equals(Mode, "viewer", StringComparison.OrdinalIgnoreCase); private WorkingMode CurrentMode => IsDesigner ? WorkingMode.Designer : WorkingMode.ViewerOnly; - private string SelectedDashboardId { get; set; } = ""; + private string SelectedDashboardId { get; set; } = string.Empty; private string DashboardKey => $"{SelectedDashboardId}-{(IsDesigner ? "designer" : "viewer")}"; private string DashboardEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/api/dashboard"; + private string HubEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/hubs/dashboards"; + + protected override async Task OnInitializedAsync() + { + await RefreshDashboards(); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(HubEndpoint) + .WithAutomaticReconnect() + .Build(); + + _hubConnection.On("DashboardsChanged", async () => + { + await RefreshDashboards(); + }); + + await _hubConnection.StartAsync(); + } protected override async Task OnParametersSetAsync() { if (dashboards.Count == 0) { - dashboards.AddRange(await DashboardApi.GetAllAsync()); + await RefreshDashboards(); } var requestedId = string.IsNullOrWhiteSpace(DashboardId) || string.Equals(DashboardId, "default", StringComparison.OrdinalIgnoreCase) @@ -119,4 +139,25 @@ var targetMode = IsDesigner ? "viewer" : "designer"; Navigation.NavigateTo($"dashboards/{SelectedDashboardId}?mode={targetMode}", replace: true); } + + private async Task RefreshDashboards() + { + var latest = await DashboardApi.GetAllAsync(); + if (latest.Count == dashboards.Count && latest.All(d => dashboards.Any(x => x.Id == d.Id && x.Name == d.Name))) + { + return; + } + + dashboards.Clear(); + dashboards.AddRange(latest); + await InvokeAsync(StateHasChanged); + } + + public async ValueTask DisposeAsync() + { + if (_hubConnection != null) + { + await _hubConnection.DisposeAsync(); + } + } } \ No newline at end of file diff --git a/DbFirst.BlazorWebApp/Components/_Imports.razor b/DbFirst.BlazorWebApp/Components/_Imports.razor index f00c849..0f26f01 100644 --- a/DbFirst.BlazorWebApp/Components/_Imports.razor +++ b/DbFirst.BlazorWebApp/Components/_Imports.razor @@ -6,6 +6,7 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop +@using Microsoft.AspNetCore.SignalR.Client @using DbFirst.BlazorWebApp @using DbFirst.BlazorWebApp.Components @using DbFirst.BlazorWebApp.Models diff --git a/DbFirst.BlazorWebApp/DbFirst.BlazorWebApp.csproj b/DbFirst.BlazorWebApp/DbFirst.BlazorWebApp.csproj index f2de668..365ec51 100644 --- a/DbFirst.BlazorWebApp/DbFirst.BlazorWebApp.csproj +++ b/DbFirst.BlazorWebApp/DbFirst.BlazorWebApp.csproj @@ -12,5 +12,9 @@ + + + +