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);
}
}