mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
0b80f09a74
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>
172 lines
5.7 KiB
C#
172 lines
5.7 KiB
C#
using AutopilotMonitor.Shared.DataAccess;
|
|
using Azure;
|
|
using Azure.Data.Tables;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Services;
|
|
|
|
/// <summary>
|
|
/// Entity for persistent Global Admin notifications stored in Table Storage.
|
|
/// All GAs share the same notification pool; dismiss is global.
|
|
/// </summary>
|
|
public class GlobalNotificationEntity : ITableEntity
|
|
{
|
|
public string PartitionKey { get; set; } = "notifications";
|
|
public string RowKey { get; set; } = string.Empty; // invertedTicks_notificationId (newest-first)
|
|
public DateTimeOffset? Timestamp { get; set; }
|
|
public ETag ETag { get; set; }
|
|
|
|
public string NotificationId { get; set; } = string.Empty;
|
|
public string Type { get; set; } = "info"; // "session_report" | "preview_signup"
|
|
public string Title { get; set; } = string.Empty;
|
|
public string Message { get; set; } = string.Empty;
|
|
public string? Href { get; set; }
|
|
public DateTime CreatedAt { get; set; }
|
|
public bool Dismissed { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// DTO returned by the API for global notifications.
|
|
/// </summary>
|
|
public class GlobalNotificationDto
|
|
{
|
|
public string Id { get; set; } = string.Empty;
|
|
public string Type { get; set; } = "info";
|
|
public string Title { get; set; } = string.Empty;
|
|
public string Message { get; set; } = string.Empty;
|
|
public string? Href { get; set; }
|
|
public DateTime CreatedAt { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Service for managing persistent Global Admin in-app notifications.
|
|
/// Notifications survive page reloads and persist until actively dismissed.
|
|
/// Delegates storage to INotificationRepository; keeps business logic (fire-and-forget, DTO conversion).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new persistent notification. Designed to be called fire-and-forget;
|
|
/// failures are logged but never thrown.
|
|
/// </summary>
|
|
public virtual async Task CreateNotificationAsync(string type, string title, string message, string? href = null)
|
|
{
|
|
try
|
|
{
|
|
var notificationId = Guid.NewGuid().ToString("N")[..12];
|
|
|
|
var notification = new GlobalNotification
|
|
{
|
|
NotificationId = notificationId,
|
|
Type = type,
|
|
Title = title,
|
|
Message = message,
|
|
Href = href,
|
|
CreatedAt = DateTime.UtcNow,
|
|
IsDismissed = false
|
|
};
|
|
|
|
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)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to create global notification ({Type}: {Title})", type, title);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all active (non-dismissed) notifications, newest first.
|
|
/// </summary>
|
|
public async Task<List<GlobalNotificationDto>> GetActiveNotificationsAsync()
|
|
{
|
|
try
|
|
{
|
|
var notifications = await _notificationRepo.GetNotificationsAsync();
|
|
|
|
return notifications.Select(n => new GlobalNotificationDto
|
|
{
|
|
Id = n.NotificationId,
|
|
Type = n.Type,
|
|
Title = n.Title,
|
|
Message = n.Message,
|
|
Href = n.Href,
|
|
CreatedAt = n.CreatedAt
|
|
}).ToList();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to retrieve global notifications");
|
|
return new List<GlobalNotificationDto>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a single notification as dismissed.
|
|
/// Returns true if found and dismissed, false if not found.
|
|
/// </summary>
|
|
public async Task<bool> DismissNotificationAsync(string notificationId)
|
|
{
|
|
try
|
|
{
|
|
var dismissed = await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty);
|
|
if (dismissed)
|
|
{
|
|
await _signalr.SendGlobalNotificationDismissedAsync(notificationId);
|
|
}
|
|
return dismissed;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to dismiss global notification {NotificationId}", notificationId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dismisses all active notifications. Returns the count of dismissed items.
|
|
/// </summary>
|
|
public async Task<int> DismissAllNotificationsAsync()
|
|
{
|
|
try
|
|
{
|
|
var count = await _notificationRepo.DismissAllNotificationsAsync();
|
|
if (count > 0)
|
|
{
|
|
await _signalr.SendGlobalNotificationDismissedAllAsync();
|
|
}
|
|
return count;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to dismiss all global notifications");
|
|
return 0;
|
|
}
|
|
}
|
|
}
|