diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor index d950800..1185092 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor @@ -1,9 +1,13 @@ +@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.OtOpcUa.Admin.Hubs @using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Core.Authorization @inject NodeAclService AclSvc @inject PermissionProbeService ProbeSvc +@inject NavigationManager Nav +@implements IAsyncDisposable

Access-control grants

@@ -205,6 +209,30 @@ else private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s; + private HubConnection? _hub; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _hub is not null) return; + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status")) + .WithAutomaticReconnect() + .Build(); + _hub.On("NodeAclChanged", async msg => + { + if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return; + _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); + await InvokeAsync(StateHasChanged); + }); + await _hub.StartAsync(); + await _hub.SendAsync("SubscribeCluster", ClusterId); + } + + public async ValueTask DisposeAsync() + { + if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; } + } + protected override async Task OnParametersSetAsync() => _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor index a5f9a38..e3d4fb4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor @@ -1,10 +1,16 @@ @page "/role-grants" +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.OtOpcUa.Admin.Hubs @using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Configuration.Services @inject ILdapGroupRoleMappingService RoleSvc @inject ClusterService ClusterSvc +@inject AclChangeNotifier Notifier +@inject NavigationManager Nav +@implements IAsyncDisposable

LDAP group → Admin role grants

@@ -147,6 +153,7 @@ else Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes, }; await RoleSvc.CreateAsync(row, CancellationToken.None); + await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None); _showForm = false; await ReloadAsync(); } @@ -156,6 +163,30 @@ else private async Task DeleteAsync(Guid id) { await RoleSvc.DeleteAsync(id, CancellationToken.None); + await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None); await ReloadAsync(); } + + private HubConnection? _hub; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _hub is not null) return; + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status")) + .WithAutomaticReconnect() + .Build(); + _hub.On("RoleGrantsChanged", async _ => + { + await ReloadAsync(); + await InvokeAsync(StateHasChanged); + }); + await _hub.StartAsync(); + await _hub.SendAsync("SubscribeFleet"); + } + + public async ValueTask DisposeAsync() + { + if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; } + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index 52859d8..0089e24 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs new file mode 100644 index 0000000..69e21be --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.SignalR; +using ZB.MOM.WW.OtOpcUa.Admin.Hubs; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Thin SignalR push helper for ACL + role-grant invalidation — slice 2 of task #196. +/// Lets the Admin services + razor pages invalidate connected peers' views without each +/// one having to know the hub wiring. Two message kinds: NodeAclChanged (cluster-scoped) +/// and RoleGrantsChanged (fleet-wide — role mappings cross cluster boundaries). +/// +/// +/// Intentionally fire-and-forget — a failed hub send doesn't rollback the DB write that +/// triggered it. Worst-case an operator sees stale data until their next poll or manual +/// refresh; better than a transient hub blip blocking the authoritative write path. +/// +public sealed class AclChangeNotifier(IHubContext fleetHub, ILogger logger) +{ + public async Task NotifyNodeAclChangedAsync(string clusterId, long generationId, CancellationToken ct) + { + try + { + var msg = new NodeAclChangedMessage(ClusterId: clusterId, GenerationId: generationId, ObservedAtUtc: DateTime.UtcNow); + await fleetHub.Clients.Group(FleetStatusHub.GroupName(clusterId)) + .SendAsync("NodeAclChanged", msg, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "NodeAclChanged push failed for cluster {ClusterId} gen {GenerationId}", clusterId, generationId); + } + } + + public async Task NotifyRoleGrantsChangedAsync(CancellationToken ct) + { + try + { + var msg = new RoleGrantsChangedMessage(ObservedAtUtc: DateTime.UtcNow); + await fleetHub.Clients.Group(FleetStatusHub.FleetGroup) + .SendAsync("RoleGrantsChanged", msg, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "RoleGrantsChanged push failed"); + } + } +} + +public sealed record NodeAclChangedMessage(string ClusterId, long GenerationId, DateTime ObservedAtUtc); +public sealed record RoleGrantsChangedMessage(DateTime ObservedAtUtc); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs index 7835055..b7f4859 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Admin.Services; -public sealed class NodeAclService(OtOpcUaConfigDbContext db) +public sealed class NodeAclService(OtOpcUaConfigDbContext db, AclChangeNotifier? notifier = null) { public Task> ListAsync(long generationId, CancellationToken ct) => db.NodeAcls.AsNoTracking() @@ -31,6 +31,10 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db) }; db.NodeAcls.Add(acl); await db.SaveChangesAsync(ct); + + if (notifier is not null) + await notifier.NotifyNodeAclChangedAsync(clusterId, draftId, ct); + return acl; } @@ -40,5 +44,8 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db) if (row is null) return; db.NodeAcls.Remove(row); await db.SaveChangesAsync(ct); + + if (notifier is not null) + await notifier.NotifyNodeAclChangedAsync(row.ClusterId, row.GenerationId, ct); } }