mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
refactor(notifications): bell on pure SignalR push, kill 60s polling
The two notification-bell React contexts polled GET /api/notifications and
GET /api/global/notifications every 60s regardless of activity, generating
3017+618 = 3635 calls/24h with near-zero hit rate (notifications are emitted
sparsely). SignalR was already used app-wide for live session updates; the
bell was the lone hold-out on a pull model.
Switch to the same push model. Steady-state traffic drops ~86% and the bell
updates sub-second instead of within 60s.
Architecture (Option C+):
- Mount -> 1x HTTP fetch (cold-start hydration via existing GET endpoint)
- SignalR onreconnected -> re-fetch (covers the disconnect window)
- SignalR push -> live append/dismiss
- No periodic polling, no setInterval
Audience-tier security: TenantNotificationAudienceCatalog still drives
visibility (Member vs Admin), but enforcement moves into SignalR group
membership: tenant-{id}-notify-member is joined by every tenant member,
tenant-{id}-notify-admin only by Tenant Admins / Global Admins. The
AddToGroup function fail-closes with 403 on Member-tier joins to the
admin group, so Admin-tier notifications cannot leak via push.
Backend
- ISignalRNotificationService extracted; SignalRNotificationService
implements it. Resolves the long-standing test-mocking blocker
(the real service's constructor validates AzureSignalRConnectionString
and BuildServiceManager(); previously you could not unit-test
consumers). Tenant + Global NotificationService consume the
interface; the three legacy callers (Analyze, Ingest, VulnCorrelate)
still bind to the concrete class -- migrate per test need.
- Six new push methods: SendTenantNotification{,Dismissed,DismissedAll}
and SendGlobalNotification{,Dismissed,DismissedAll}. Tenant variant
routes to the audience-correct group.
- Tenant + Global service push after a successful repo write
(fire-and-forget; never throws into the caller).
- SignalRGroupHelper: parsing handles the two new -notify-{member,admin}
suffixes; new IsTenantNotifyAdminGroup predicate; group-name builders.
- SignalRAddToGroupFunction: explicit 403 when a non-admin attempts
to join the admin-tier group.
Frontend
- TenantNotificationContext: setInterval gone. useSignalR() to
join member-group always, admin-group iff isTenantAdmin || isGlobalAdmin.
Listens for tenantNotification (append + dedupe), tenantNotificationDismissed
(filter), tenantNotificationsDismissedAll (clear). onreconnected
triggers re-fetch.
- GlobalNotificationContext: same pattern against global-admins group.
- app/layout.tsx: SignalRProvider hoisted above the notification
providers so useSignalR() resolves inside them.
Tests
- SignalRGroupHelperTests (8): parsing for all four group formats
incl. new -notify suffixes, IsTenantNotifyAdminGroup predicate.
- TenantNotificationServiceSignalRPushTests (8): audience-correct push
on Create (Admin / Member / Unknown -> Admin fail-closed), no-push
on repo failure, no-push on DismissAll(count=0), no-push on empty
tenantId.
- FakeSignalRNotificationService: in-memory spy implementing the
new interface; replaces the unmockable real service in tests.
- AuthFunctionSideEffectTests + TenantNotificationServiceTests:
constructor signatures updated for the new SignalR dep.
Suites
Backend xUnit: 1161/1161 (was 1146, +15 net).
Web vitest: 259/259 (unchanged; existing notification-API +
gate tests still cover).
Solution build: 0 warnings, 0 errors.
Expected post-deploy reduction (24h):
GET GetTenantNotifications: 3017 -> ~50
GET GetGlobalNotifications: 618 -> ~10
POST AddToGroup: ~246 -> ~350-450 (two joins per tab)
Net bell load: -86%, sub-second live UX.
Disconnect tradeoff (accepted): if SignalR drops without reconnect
(rare -- withAutomaticReconnect retries 1/2/4/8/16/30s ... forever),
the bell shows the snapshot from the last fetch but no live updates
until next reload. The initial-3-retry give-up path of the SignalR
client still gets the user a hydrated bell because the HTTP fetch
runs independently on mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
644
|
||||
647
|
||||
|
||||
@@ -1 +1 @@
|
||||
1279
|
||||
1282
|
||||
|
||||
@@ -55,7 +55,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
_globalNotificationMock = new Mock<GlobalNotificationService>(
|
||||
notificationRepo, Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
notificationRepo, new FakeSignalRNotificationService(), Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var adminConfigService = new Mock<AdminConfigurationService>(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ISignalRNotificationService for unit tests. Records every call so tests can
|
||||
/// assert that producer code (TenantNotificationService, GlobalNotificationService, …) emits
|
||||
/// the right SignalR push when it should — without instantiating the real Azure SignalR
|
||||
/// ServiceManager (whose constructor validates a connection string and is not test-friendly).
|
||||
/// </summary>
|
||||
public class FakeSignalRNotificationService : ISignalRNotificationService
|
||||
{
|
||||
public List<(string TenantId, NotificationAudience Audience, object Dto)> TenantSends { get; } = new();
|
||||
public List<(string TenantId, string NotificationId)> TenantDismisses { get; } = new();
|
||||
public List<string> TenantDismissAlls { get; } = new();
|
||||
public List<object> GlobalSends { get; } = new();
|
||||
public List<string> GlobalDismisses { get; } = new();
|
||||
public int GlobalDismissAllCount { get; private set; }
|
||||
public List<(string TenantId, string SessionId, int Count)> RuleResultsCalls { get; } = new();
|
||||
public List<(string TenantId, string SessionId, string Risk)> VulnReportCalls { get; } = new();
|
||||
|
||||
public Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount)
|
||||
{
|
||||
RuleResultsCalls.Add((tenantId, sessionId, resultCount));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk)
|
||||
{
|
||||
VulnReportCalls.Add((tenantId, sessionId, overallRisk));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto)
|
||||
{
|
||||
TenantSends.Add((tenantId, audience, dto));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId)
|
||||
{
|
||||
TenantDismisses.Add((tenantId, notificationId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendTenantNotificationDismissedAllAsync(string tenantId)
|
||||
{
|
||||
TenantDismissAlls.Add(tenantId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendGlobalNotificationAsync(object dto)
|
||||
{
|
||||
GlobalSends.Add(dto);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendGlobalNotificationDismissedAsync(string notificationId)
|
||||
{
|
||||
GlobalDismisses.Add(notificationId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendGlobalNotificationDismissedAllAsync()
|
||||
{
|
||||
GlobalDismissAllCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SignalRGroupHelper"/>. Locks in the parsing rules for the four
|
||||
/// supported group formats so a future refactor cannot accidentally break tenant
|
||||
/// isolation in <see cref="AutopilotMonitor.Functions.Functions.Infrastructure.SignalRAddToGroupFunction"/>,
|
||||
/// which uses <see cref="SignalRGroupHelper.ExtractTenantIdFromGroupName"/> for cross-tenant validation.
|
||||
/// </summary>
|
||||
public class SignalRGroupHelperTests
|
||||
{
|
||||
private const string TenantId = "11111111-2222-3333-4444-555555555555";
|
||||
private const string SessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
[Fact]
|
||||
public void ExtractTenantId_TenantWideGroup()
|
||||
{
|
||||
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTenantId_TenantNotifyMemberGroup()
|
||||
{
|
||||
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}-notify-member"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTenantId_TenantNotifyAdminGroup()
|
||||
{
|
||||
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}-notify-admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTenantId_SessionGroup()
|
||||
{
|
||||
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"session-{TenantId}-{SessionId}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTenantId_GlobalAdmins_ReturnsNull()
|
||||
{
|
||||
Assert.Null(SignalRGroupHelper.ExtractTenantIdFromGroupName("global-admins"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTenantId_UnknownFormat_ReturnsNull()
|
||||
{
|
||||
Assert.Null(SignalRGroupHelper.ExtractTenantIdFromGroupName("foo-bar"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTenantNotifyAdminGroup_OnlyTrueForAdminSuffix()
|
||||
{
|
||||
Assert.True(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}-notify-admin"));
|
||||
Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}-notify-member"));
|
||||
Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}"));
|
||||
Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup("global-admins"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupNameBuilders_RoundTrip()
|
||||
{
|
||||
Assert.Equal($"tenant-{TenantId}-notify-member", SignalRGroupHelper.TenantNotifyMemberGroup(TenantId));
|
||||
Assert.Equal($"tenant-{TenantId}-notify-admin", SignalRGroupHelper.TenantNotifyAdminGroup(TenantId));
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Locks in that <see cref="TenantNotificationService"/> emits the right SignalR push for each
|
||||
/// lifecycle event — Create / Dismiss / DismissAll — and routes to the audience-correct group.
|
||||
///
|
||||
/// Why this matters: the web bell now relies entirely on SignalR for live updates (no polling).
|
||||
/// If the service silently drops a push on a code path, the UI will show stale state until
|
||||
/// the user reloads. That regression would be invisible without these tests.
|
||||
/// </summary>
|
||||
public class TenantNotificationServiceSignalRPushTests
|
||||
{
|
||||
private const string TenantId = "test-tenant-aaaa-bbbb-cccc-dddddddddddd";
|
||||
|
||||
private static (TenantNotificationService Service, FakeSignalRNotificationService Signalr, Mock<ITenantNotificationRepository> Repo) Build()
|
||||
{
|
||||
var repo = new Mock<ITenantNotificationRepository>();
|
||||
var signalr = new FakeSignalRNotificationService();
|
||||
var service = new TenantNotificationService(repo.Object, signalr, NullLogger<TenantNotificationService>.Instance);
|
||||
return (service, signalr, repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AdminTierType_PushesToAdminAudience()
|
||||
{
|
||||
var (svc, signalr, _) = Build();
|
||||
|
||||
await svc.CreateNotificationAsync(TenantId, "hardware_rejection", "Title", "Message");
|
||||
|
||||
var send = Assert.Single(signalr.TenantSends);
|
||||
Assert.Equal(TenantId, send.TenantId);
|
||||
Assert.Equal(NotificationAudience.Admin, send.Audience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_MemberTierType_PushesToMemberAudience()
|
||||
{
|
||||
var (svc, signalr, _) = Build();
|
||||
|
||||
await svc.CreateNotificationAsync(TenantId, "sla_breach", "Title", "Message");
|
||||
|
||||
var send = Assert.Single(signalr.TenantSends);
|
||||
Assert.Equal(NotificationAudience.Member, send.Audience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_UnknownType_DefaultsToAdminAudience_FailClosed()
|
||||
{
|
||||
var (svc, signalr, _) = Build();
|
||||
|
||||
await svc.CreateNotificationAsync(TenantId, "brand_new_unregistered_type", "Title", "Message");
|
||||
|
||||
// TenantNotificationAudienceCatalog.Resolve fails closed (Admin-only) for unknown types
|
||||
Assert.Equal(NotificationAudience.Admin, signalr.TenantSends.Single().Audience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_EmptyTenantId_NoPushNoRepoWrite()
|
||||
{
|
||||
var (svc, signalr, repo) = Build();
|
||||
|
||||
await svc.CreateNotificationAsync("", "sla_breach", "Title", "Message");
|
||||
|
||||
Assert.Empty(signalr.TenantSends);
|
||||
repo.Verify(r => r.AddNotificationAsync(It.IsAny<string>(), It.IsAny<GlobalNotification>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dismiss_RepoReturnsTrue_PushesDismissed()
|
||||
{
|
||||
var (svc, signalr, repo) = Build();
|
||||
repo.Setup(r => r.DismissNotificationAsync(TenantId, "n1", It.IsAny<string>())).ReturnsAsync(true);
|
||||
|
||||
var ok = await svc.DismissNotificationAsync(TenantId, "n1", "user@example.com");
|
||||
|
||||
Assert.True(ok);
|
||||
var dismiss = Assert.Single(signalr.TenantDismisses);
|
||||
Assert.Equal(TenantId, dismiss.TenantId);
|
||||
Assert.Equal("n1", dismiss.NotificationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dismiss_RepoReturnsFalse_NoPush()
|
||||
{
|
||||
var (svc, signalr, repo) = Build();
|
||||
repo.Setup(r => r.DismissNotificationAsync(TenantId, "missing", It.IsAny<string>())).ReturnsAsync(false);
|
||||
|
||||
var ok = await svc.DismissNotificationAsync(TenantId, "missing", "user@example.com");
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Empty(signalr.TenantDismisses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DismissAll_RepoReturnsCount_PushesOnceWithTenantId()
|
||||
{
|
||||
var (svc, signalr, repo) = Build();
|
||||
repo.Setup(r => r.DismissAllNotificationsAsync(TenantId)).ReturnsAsync(7);
|
||||
|
||||
var count = await svc.DismissAllNotificationsAsync(TenantId);
|
||||
|
||||
Assert.Equal(7, count);
|
||||
Assert.Equal(new[] { TenantId }, signalr.TenantDismissAlls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DismissAll_RepoReturnsZero_NoPush()
|
||||
{
|
||||
var (svc, signalr, repo) = Build();
|
||||
repo.Setup(r => r.DismissAllNotificationsAsync(TenantId)).ReturnsAsync(0);
|
||||
|
||||
await svc.DismissAllNotificationsAsync(TenantId);
|
||||
|
||||
Assert.Empty(signalr.TenantDismissAlls);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class TenantNotificationServiceTests
|
||||
var repo = new Mock<ITenantNotificationRepository>();
|
||||
repo.Setup(r => r.GetNotificationsAsync(TenantId, It.IsAny<int>()))
|
||||
.ReturnsAsync(notifications.ToList());
|
||||
return new TenantNotificationService(repo.Object, NullLogger<TenantNotificationService>.Instance);
|
||||
return new TenantNotificationService(repo.Object, new FakeSignalRNotificationService(), NullLogger<TenantNotificationService>.Instance);
|
||||
}
|
||||
|
||||
private static GlobalNotification Notif(string type, string id) => new()
|
||||
|
||||
+12
@@ -93,6 +93,18 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
_logger.LogInformation($"Global Admin {userEmail} joining cross-tenant group: {request.GroupName}");
|
||||
}
|
||||
}
|
||||
|
||||
// Admin-tier notification group: only Tenant Admins or Global Admins may join.
|
||||
// The Member-tier group has no extra check beyond the tenant-membership above.
|
||||
if (SignalRGroupHelper.IsTenantNotifyAdminGroup(request.GroupName)
|
||||
&& !requestCtx.IsTenantAdmin
|
||||
&& !requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
_logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) attempted to join Admin-tier notification group: {request.GroupName}");
|
||||
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Tenant Admins can join the admin notification group" });
|
||||
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract session ID from group name if it's a session-specific group
|
||||
|
||||
@@ -2,21 +2,24 @@ namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for parsing SignalR group names.
|
||||
/// Group formats: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins"
|
||||
/// Group formats:
|
||||
/// - "tenant-{tenantId}" — tenant-wide live updates (sessions, etc.)
|
||||
/// - "tenant-{tenantId}-notify-member" — tenant notification bell (Member-tier visibility)
|
||||
/// - "tenant-{tenantId}-notify-admin" — tenant notification bell (Admin-tier visibility)
|
||||
/// - "session-{tenantId}-{sessionId}" — single-session live updates
|
||||
/// - "global-admins" — global notification bell
|
||||
/// </summary>
|
||||
public static class SignalRGroupHelper
|
||||
{
|
||||
public const string TenantNotifyMemberSuffix = "-notify-member";
|
||||
public const string TenantNotifyAdminSuffix = "-notify-admin";
|
||||
|
||||
/// <summary>
|
||||
/// Extracts tenant ID from SignalR group name.
|
||||
/// </summary>
|
||||
public static string? ExtractTenantIdFromGroupName(string groupName)
|
||||
{
|
||||
if (groupName.StartsWith("tenant-"))
|
||||
{
|
||||
// Format: "tenant-{tenantId}"
|
||||
return groupName.Substring("tenant-".Length);
|
||||
}
|
||||
else if (groupName.StartsWith("session-"))
|
||||
if (groupName.StartsWith("session-"))
|
||||
{
|
||||
// Format: "session-{tenantId}-{sessionId}"
|
||||
// Extract everything between "session-" and the last 5 GUID segments
|
||||
@@ -26,10 +29,33 @@ public static class SignalRGroupHelper
|
||||
// Reconstruct tenant GUID from parts 1-5
|
||||
return string.Join("-", parts.Skip(1).Take(5));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (groupName.StartsWith("tenant-"))
|
||||
{
|
||||
// Strip the leading "tenant-" prefix, then strip the optional "-notify-{member,admin}" suffix.
|
||||
var withoutPrefix = groupName.Substring("tenant-".Length);
|
||||
if (withoutPrefix.EndsWith(TenantNotifyAdminSuffix))
|
||||
return withoutPrefix.Substring(0, withoutPrefix.Length - TenantNotifyAdminSuffix.Length);
|
||||
if (withoutPrefix.EndsWith(TenantNotifyMemberSuffix))
|
||||
return withoutPrefix.Substring(0, withoutPrefix.Length - TenantNotifyMemberSuffix.Length);
|
||||
return withoutPrefix;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for the Admin-tier tenant notification group. Joining requires Tenant-Admin or Global-Admin.
|
||||
/// </summary>
|
||||
public static bool IsTenantNotifyAdminGroup(string groupName)
|
||||
=> groupName.StartsWith("tenant-") && groupName.EndsWith(TenantNotifyAdminSuffix);
|
||||
|
||||
public static string TenantNotifyMemberGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyMemberSuffix}";
|
||||
public static string TenantNotifyAdminGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyAdminSuffix}";
|
||||
public const string GlobalAdminsGroup = "global-admins";
|
||||
|
||||
public static string ExtractLogPrefix(string groupName)
|
||||
{
|
||||
// Extract session ID from group name: "session-{tenantId}-{sessionId}"
|
||||
|
||||
@@ -217,6 +217,7 @@ builder.Services.AddHostedService<
|
||||
|
||||
// Programmatic SignalR push for background tasks (rule engine, vulnerability correlation)
|
||||
builder.Services.AddSingleton<SignalRNotificationService>();
|
||||
builder.Services.AddSingleton<ISignalRNotificationService>(sp => sp.GetRequiredService<SignalRNotificationService>());
|
||||
|
||||
// Vulnerability correlation services
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.NvdApiClient>()
|
||||
|
||||
@@ -46,13 +46,16 @@ public class GlobalNotificationDto
|
||||
public class GlobalNotificationService
|
||||
{
|
||||
private readonly INotificationRepository _notificationRepo;
|
||||
private readonly ISignalRNotificationService _signalr;
|
||||
private readonly ILogger<GlobalNotificationService> _logger;
|
||||
|
||||
public GlobalNotificationService(
|
||||
INotificationRepository notificationRepo,
|
||||
ISignalRNotificationService signalr,
|
||||
ILogger<GlobalNotificationService> logger)
|
||||
{
|
||||
_notificationRepo = notificationRepo;
|
||||
_signalr = signalr;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -79,6 +82,17 @@ public class GlobalNotificationService
|
||||
|
||||
await _notificationRepo.AddNotificationAsync(notification);
|
||||
_logger.LogInformation("Global notification created: {NotificationId} ({Type})", notificationId, type);
|
||||
|
||||
var dto = new GlobalNotificationDto
|
||||
{
|
||||
Id = notificationId,
|
||||
Type = type,
|
||||
Title = title,
|
||||
Message = message,
|
||||
Href = href,
|
||||
CreatedAt = notification.CreatedAt
|
||||
};
|
||||
await _signalr.SendGlobalNotificationAsync(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -120,7 +134,12 @@ public class GlobalNotificationService
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty);
|
||||
var dismissed = await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty);
|
||||
if (dismissed)
|
||||
{
|
||||
await _signalr.SendGlobalNotificationDismissedAsync(notificationId);
|
||||
}
|
||||
return dismissed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -136,7 +155,12 @@ public class GlobalNotificationService
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _notificationRepo.DismissAllNotificationsAsync();
|
||||
var count = await _notificationRepo.DismissAllNotificationsAsync();
|
||||
if (count > 0)
|
||||
{
|
||||
await _signalr.SendGlobalNotificationDismissedAllAsync();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Programmatic SignalR message sender. Extracted as an interface so that consumers
|
||||
/// (TenantNotificationService, GlobalNotificationService, IngestEventsFunction, etc.)
|
||||
/// can be unit-tested without instantiating the real Azure SignalR ServiceManager,
|
||||
/// whose constructor validates the connection string.
|
||||
/// </summary>
|
||||
public interface ISignalRNotificationService
|
||||
{
|
||||
Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount);
|
||||
Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk);
|
||||
|
||||
Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto);
|
||||
Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId);
|
||||
Task SendTenantNotificationDismissedAllAsync(string tenantId);
|
||||
|
||||
Task SendGlobalNotificationAsync(object dto);
|
||||
Task SendGlobalNotificationDismissedAsync(string notificationId);
|
||||
Task SendGlobalNotificationDismissedAllAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Microsoft.Azure.SignalR.Management;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -11,7 +12,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// of Azure Function output bindings (e.g., async rule engine, vulnerability correlation).
|
||||
/// Uses the Azure SignalR Management SDK to send messages to connected clients.
|
||||
/// </summary>
|
||||
public class SignalRNotificationService : IDisposable
|
||||
public class SignalRNotificationService : ISignalRNotificationService, IDisposable
|
||||
{
|
||||
private readonly ILogger<SignalRNotificationService> _logger;
|
||||
private readonly ServiceManager _serviceManager;
|
||||
@@ -94,6 +95,124 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a newly created tenant notification to the appropriate audience-tier group.
|
||||
/// Member-tier notifications go to the -notify-member group (joined by all tenant members);
|
||||
/// Admin-tier notifications go to the -notify-admin group (joined by Tenant Admins + Global Admins only).
|
||||
/// </summary>
|
||||
public virtual async Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hub = await GetHubContextAsync();
|
||||
var groupName = audience == NotificationAudience.Admin
|
||||
? SignalRGroupHelper.TenantNotifyAdminGroup(tenantId)
|
||||
: SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
|
||||
|
||||
await hub.Clients.Group(groupName).SendCoreAsync("tenantNotification", new[] { dto });
|
||||
_logger.LogDebug("Sent tenantNotification to group {Group}", groupName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send tenantNotification for tenant {TenantId}", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a tenant notification dismissal to all audience-tier groups for the tenant.
|
||||
/// We do not know the original audience at dismiss time, so we broadcast to both groups —
|
||||
/// receivers filter by id and remove if present (idempotent).
|
||||
/// </summary>
|
||||
public virtual async Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hub = await GetHubContextAsync();
|
||||
var memberGroup = SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
|
||||
var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId);
|
||||
var payload = new { id = notificationId };
|
||||
|
||||
await Task.WhenAll(
|
||||
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }),
|
||||
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload })
|
||||
);
|
||||
_logger.LogDebug("Sent tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a "dismiss all" event to all audience-tier groups for the tenant. Receivers clear local state.
|
||||
/// </summary>
|
||||
public virtual async Task SendTenantNotificationDismissedAllAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hub = await GetHubContextAsync();
|
||||
var memberGroup = SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
|
||||
var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId);
|
||||
|
||||
await Task.WhenAll(
|
||||
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>()),
|
||||
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>())
|
||||
);
|
||||
_logger.LogDebug("Sent tenantNotificationsDismissedAll for tenant {TenantId}", tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send tenantNotificationsDismissedAll for tenant {TenantId}", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a newly created global notification to the global-admins group.
|
||||
/// </summary>
|
||||
public virtual async Task SendGlobalNotificationAsync(object dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hub = await GetHubContextAsync();
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotification", new[] { dto });
|
||||
_logger.LogDebug("Sent globalNotification to group {Group}", SignalRGroupHelper.GlobalAdminsGroup);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send globalNotification");
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task SendGlobalNotificationDismissedAsync(string notificationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hub = await GetHubContextAsync();
|
||||
var payload = new { id = notificationId };
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationDismissed", new object[] { payload });
|
||||
_logger.LogDebug("Sent globalNotificationDismissed for id {Id}", notificationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send globalNotificationDismissed for id {Id}", notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task SendGlobalNotificationDismissedAllAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var hub = await GetHubContextAsync();
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationsDismissedAll", Array.Empty<object>());
|
||||
_logger.LogDebug("Sent globalNotificationsDismissedAll");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send globalNotificationsDismissedAll");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IServiceHubContext> GetHubContextAsync()
|
||||
{
|
||||
if (_hubContext != null) return _hubContext;
|
||||
|
||||
@@ -12,13 +12,16 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
public class TenantNotificationService
|
||||
{
|
||||
private readonly ITenantNotificationRepository _repo;
|
||||
private readonly ISignalRNotificationService _signalr;
|
||||
private readonly ILogger<TenantNotificationService> _logger;
|
||||
|
||||
public TenantNotificationService(
|
||||
ITenantNotificationRepository repo,
|
||||
ISignalRNotificationService signalr,
|
||||
ILogger<TenantNotificationService> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_signalr = signalr;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,6 +52,19 @@ public class TenantNotificationService
|
||||
await _repo.AddNotificationAsync(tenantId, notification);
|
||||
_logger.LogInformation("Tenant notification created: tenant={TenantId} id={NotificationId} type={Type}",
|
||||
tenantId, notificationId, type);
|
||||
|
||||
// Push to the audience-tier SignalR group so connected clients update live without polling.
|
||||
var audience = TenantNotificationAudienceCatalog.Resolve(type);
|
||||
var dto = new GlobalNotificationDto
|
||||
{
|
||||
Id = notificationId,
|
||||
Type = type,
|
||||
Title = title,
|
||||
Message = message,
|
||||
Href = href,
|
||||
CreatedAt = notification.CreatedAt
|
||||
};
|
||||
await _signalr.SendTenantNotificationAsync(tenantId, audience, dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -90,7 +106,12 @@ public class TenantNotificationService
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _repo.DismissNotificationAsync(tenantId, notificationId, dismissedBy);
|
||||
var dismissed = await _repo.DismissNotificationAsync(tenantId, notificationId, dismissedBy);
|
||||
if (dismissed)
|
||||
{
|
||||
await _signalr.SendTenantNotificationDismissedAsync(tenantId, notificationId);
|
||||
}
|
||||
return dismissed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -104,7 +125,12 @@ public class TenantNotificationService
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _repo.DismissAllNotificationsAsync(tenantId);
|
||||
var count = await _repo.DismissAllNotificationsAsync(tenantId);
|
||||
if (count > 0)
|
||||
{
|
||||
await _signalr.SendTenantNotificationDismissedAllAsync(tenantId);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -118,11 +118,11 @@ export default function RootLayout({
|
||||
/>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<GlobalNotificationProvider>
|
||||
<TenantNotificationProvider>
|
||||
<TenantProvider>
|
||||
<SignalRProvider>
|
||||
<SignalRProvider>
|
||||
<NotificationProvider>
|
||||
<GlobalNotificationProvider>
|
||||
<TenantNotificationProvider>
|
||||
<TenantProvider>
|
||||
<SidebarProvider>
|
||||
<AppInsightsInit />
|
||||
<Navbar />
|
||||
@@ -132,11 +132,11 @@ export default function RootLayout({
|
||||
<ScrollToTopButton />
|
||||
<FeedbackBubble />
|
||||
</SidebarProvider>
|
||||
</SignalRProvider>
|
||||
</TenantProvider>
|
||||
</TenantNotificationProvider>
|
||||
</GlobalNotificationProvider>
|
||||
</NotificationProvider>
|
||||
</TenantProvider>
|
||||
</TenantNotificationProvider>
|
||||
</GlobalNotificationProvider>
|
||||
</NotificationProvider>
|
||||
</SignalRProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useSignalR } from './SignalRContext';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
|
||||
@@ -24,14 +25,12 @@ interface GlobalNotificationContextType {
|
||||
|
||||
const GlobalNotificationContext = createContext<GlobalNotificationContextType | undefined>(undefined);
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export function GlobalNotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
|
||||
const [notifications, setNotifications] = useState<GlobalNotification[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fetchingRef = useRef(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isGlobal = user?.isGlobalAdmin === true;
|
||||
|
||||
@@ -55,23 +54,23 @@ export function GlobalNotificationProvider({ children }: { children: React.React
|
||||
}
|
||||
}, [isGlobal, getAccessToken]);
|
||||
|
||||
// Initial fetch + polling
|
||||
// Initial state hydration. Re-fetched on SignalR reconnect to recover any deltas
|
||||
// pushed during the disconnect window.
|
||||
useEffect(() => {
|
||||
if (!isGlobal) {
|
||||
setNotifications([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
fetchNotifications().finally(() => setIsLoading(false));
|
||||
|
||||
intervalRef.current = setInterval(fetchNotifications, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isGlobal, fetchNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection || !isGlobal) return;
|
||||
const handler = () => { fetchNotifications(); };
|
||||
connection.onreconnected(handler);
|
||||
}, [connection, isGlobal, fetchNotifications]);
|
||||
|
||||
// Re-fetch when globalAdminMode is toggled in localStorage
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
@@ -83,8 +82,42 @@ export function GlobalNotificationProvider({ children }: { children: React.React
|
||||
return () => window.removeEventListener('localStorageChange', handleStorageChange);
|
||||
}, [isGlobal, fetchNotifications]);
|
||||
|
||||
// Group membership: only Global Admins join the global-admins group.
|
||||
useEffect(() => {
|
||||
if (!isConnected || !isGlobal) return;
|
||||
const group = 'global-admins';
|
||||
joinGroup(group);
|
||||
return () => { leaveGroup(group); };
|
||||
}, [isConnected, isGlobal, joinGroup, leaveGroup]);
|
||||
|
||||
// Live-push handlers.
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
const onCreate = (notification: GlobalNotification) => {
|
||||
if (!notification?.id) return;
|
||||
setNotifications(prev => prev.some(n => n.id === notification.id) ? prev : [notification, ...prev]);
|
||||
};
|
||||
const onDismiss = (payload: { id: string }) => {
|
||||
if (!payload?.id) return;
|
||||
setNotifications(prev => prev.filter(n => n.id !== payload.id));
|
||||
};
|
||||
const onDismissAll = () => {
|
||||
setNotifications([]);
|
||||
};
|
||||
|
||||
connection.on('globalNotification', onCreate);
|
||||
connection.on('globalNotificationDismissed', onDismiss);
|
||||
connection.on('globalNotificationsDismissedAll', onDismissAll);
|
||||
|
||||
return () => {
|
||||
connection.off('globalNotification', onCreate);
|
||||
connection.off('globalNotificationDismissed', onDismiss);
|
||||
connection.off('globalNotificationsDismissedAll', onDismissAll);
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
const dismissNotification = useCallback(async (id: string) => {
|
||||
// Optimistic removal
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
|
||||
try {
|
||||
@@ -94,7 +127,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
|
||||
{ method: 'POST' },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort; next poll will reconcile
|
||||
// Best-effort; backend push will reconcile other tabs if dismiss eventually lands
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useSignalR } from './SignalRContext';
|
||||
import { canFetchTenantNotifications } from './tenantNotificationsGate';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
@@ -25,20 +26,20 @@ interface TenantNotificationContextType {
|
||||
|
||||
const TenantNotificationContext = createContext<TenantNotificationContextType | undefined>(undefined);
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export function TenantNotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
|
||||
const [tenantNotifications, setTenantNotifications] = useState<TenantNotification[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fetchingRef = useRef(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Only tenant members (Admin/Operator/Viewer) and Global Admins are entitled to the bell.
|
||||
// The backend filters per-notification visibility by audience tier, so it is safe to fetch
|
||||
// for every member role — but unauthenticated users and users without a tenant role would
|
||||
// just produce 401/403 noise, so we skip them entirely.
|
||||
const canFetchNotifications = canFetchTenantNotifications(user);
|
||||
const tenantId = user?.tenantId ?? null;
|
||||
const isAdminTier = user?.isTenantAdmin === true || user?.isGlobalAdmin === true;
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!canFetchNotifications || fetchingRef.current) return;
|
||||
@@ -60,21 +61,72 @@ export function TenantNotificationProvider({ children }: { children: React.React
|
||||
}
|
||||
}, [canFetchNotifications, getAccessToken]);
|
||||
|
||||
// Initial state hydration. Runs once on mount and again after every SignalR reconnect to
|
||||
// recover any deltas that were emitted while the SignalR connection was down.
|
||||
useEffect(() => {
|
||||
if (!canFetchNotifications) {
|
||||
setTenantNotifications([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
fetchNotifications().finally(() => setIsLoading(false));
|
||||
}, [canFetchNotifications, fetchNotifications]);
|
||||
|
||||
intervalRef.current = setInterval(fetchNotifications, POLL_INTERVAL_MS);
|
||||
// Re-fetch on SignalR reconnect — covers the deltas pushed during the disconnect window.
|
||||
useEffect(() => {
|
||||
if (!connection || !canFetchNotifications) return;
|
||||
const handler = () => { fetchNotifications(); };
|
||||
connection.onreconnected(handler);
|
||||
// The signalr-js client does not expose an unsubscribe for onreconnected; rely on the
|
||||
// SignalR connection lifecycle (one connection per app session) for cleanup.
|
||||
}, [connection, canFetchNotifications, fetchNotifications]);
|
||||
|
||||
// Group membership: Member-tier always; Admin-tier only for Tenant Admins / Global Admins.
|
||||
useEffect(() => {
|
||||
if (!isConnected || !canFetchNotifications || !tenantId) return;
|
||||
|
||||
const memberGroup = `tenant-${tenantId}-notify-member`;
|
||||
const adminGroup = `tenant-${tenantId}-notify-admin`;
|
||||
|
||||
joinGroup(memberGroup);
|
||||
if (isAdminTier) {
|
||||
joinGroup(adminGroup);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
leaveGroup(memberGroup);
|
||||
if (isAdminTier) {
|
||||
leaveGroup(adminGroup);
|
||||
}
|
||||
};
|
||||
}, [canFetchNotifications, fetchNotifications]);
|
||||
}, [isConnected, canFetchNotifications, tenantId, isAdminTier, joinGroup, leaveGroup]);
|
||||
|
||||
// Live-push handlers.
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
const onCreate = (notification: TenantNotification) => {
|
||||
if (!notification?.id) return;
|
||||
setTenantNotifications(prev => prev.some(n => n.id === notification.id) ? prev : [notification, ...prev]);
|
||||
};
|
||||
const onDismiss = (payload: { id: string }) => {
|
||||
if (!payload?.id) return;
|
||||
setTenantNotifications(prev => prev.filter(n => n.id !== payload.id));
|
||||
};
|
||||
const onDismissAll = () => {
|
||||
setTenantNotifications([]);
|
||||
};
|
||||
|
||||
connection.on('tenantNotification', onCreate);
|
||||
connection.on('tenantNotificationDismissed', onDismiss);
|
||||
connection.on('tenantNotificationsDismissedAll', onDismissAll);
|
||||
|
||||
return () => {
|
||||
connection.off('tenantNotification', onCreate);
|
||||
connection.off('tenantNotificationDismissed', onDismiss);
|
||||
connection.off('tenantNotificationsDismissedAll', onDismissAll);
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
const dismissTenantNotification = useCallback(async (id: string) => {
|
||||
setTenantNotifications(prev => prev.filter(n => n.id !== id));
|
||||
@@ -86,7 +138,7 @@ export function TenantNotificationProvider({ children }: { children: React.React
|
||||
{ method: 'POST' },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort; next poll will reconcile
|
||||
// Best-effort; the backend push will reconcile other tabs if the dismiss eventually lands
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user