Rating feedback raises its own ops event; Alert Rules in the Ops menu with a search (D-362)

A rating from the feedback bubble reached only the direct Telegram ping: it was the one customer
submission without an ops event and a bell. It now raises FeedbackRatingReceived (Tenant, Info) and a
Global Admin bell, so an alert rule can route it to the Push channel. Message and details carry the
stars and the comment length, never the comment or the sender; the Telegram ping stays until it is
retired. The answer route admits members only (MemberRead, like feedback/general): it now feeds an
operator alert, and the bubble only ever asks members.

The alert rules page was linked from no menu. Ops > Alert Rules points at it, and a search in its
header narrows the rules by event type and category; it filters the display only, so a save keeps
every rule. navSectionReachability.test.ts holds the sidebar and the section registries together in
both directions. The ops events page and the rules search share one SearchInput.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-15 13:24:18 +02:00
parent dc4f6ed28b
commit 0b813891e4
19 changed files with 673 additions and 88 deletions
@@ -208,6 +208,20 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
}
/// <summary>
/// Both feedback answers raise an operator alert (FeedbackReceived, FeedbackRatingReceived), so both
/// admit members only: AuthenticatedUser would let any Entra sign-in reach the alert channels. The
/// bubble's eligibility poll stays open to every signed-in user and answers "not eligible" to a
/// non-member.
/// </summary>
[Fact]
public void FeedbackAnswers_AreMemberOnly_StatusPollStaysOpen()
{
Assert.Equal(EndpointPolicy.MemberRead, EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/feedback")?.Policy);
Assert.Equal(EndpointPolicy.MemberRead, EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/feedback/general")?.Policy);
Assert.Equal(EndpointPolicy.AuthenticatedUser, EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/feedback/status")?.Policy);
}
/// <summary>
/// Unregistered routes must return null (fail-closed).
/// </summary>
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Feedback;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The rating bubble's answer route, driven through <c>ProcessSubmitAsync</c> (the HTTP shell only
/// reads the body and maps the validation error). Pins that a recorded rating, and nothing else,
/// raises <c>FeedbackRatingReceived</c>, the Global Admin bell and the Telegram ping, and that the
/// alert carries neither the comment nor the sender.
/// </summary>
public sealed class FeedbackFunctionSubmitTests
{
private const string TenantId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
private const string NormalizedTenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string Upn = "alice@contoso.invalid";
private const string DisplayName = "Alice (Contoso)";
private const string Comment = "The timeline filter could remember my last choice.";
private static readonly DateTime Now = new(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
{
var h = new Harness();
var error = await h.Submit(4, " " + Comment + "\n");
Assert.Null(error);
var row = Assert.Single(h.Repo.Written);
Assert.True(row.Submitted);
Assert.Equal(4, row.Rating);
Assert.Equal(Comment, row.Comment);
Assert.Equal(Now, row.InteractedAt);
var ops = Assert.Single(h.OpsEvents);
Assert.Equal(OpsEventTypes.FeedbackRatingReceived, ops.EventType);
Assert.Equal(OpsEventCategory.Tenant, ops.Category);
Assert.Equal(OpsEventSeverity.Info, ops.Severity);
Assert.Equal(NormalizedTenantId, ops.TenantId);
Assert.Equal(Upn, ops.UserId);
Assert.Equal($"Feedback rating 4/5 received from contoso.invalid ({NormalizedTenantId})", ops.Message);
using var details = JsonDocument.Parse(ops.Details!);
Assert.Equal(4, details.RootElement.GetProperty("rating").GetInt32());
Assert.Equal(Comment.Length, details.RootElement.GetProperty("commentLength").GetInt32());
Assert.Equal("contoso.invalid", details.RootElement.GetProperty("domainName").GetString());
Assert.Equal(Constants.PortalUserFeedbackPath, details.RootElement.GetProperty("portalPath").GetString());
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", Constants.PortalUserFeedbackPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
}
[Fact]
public async Task TheAlert_CarriesNeitherCommentNorSender()
{
// Channels bound to FeedbackRatingReceived (Telegram, Slack, webhooks, e-mail) receive message
// and details; the comment is read in the portal. Personal data must never ride along.
var h = new Harness();
await h.Submit(2, Comment);
var ops = Assert.Single(h.OpsEvents);
foreach (var leaked in new[] { Comment, Upn, DisplayName })
{
Assert.DoesNotContain(leaked, ops.Message);
Assert.DoesNotContain(leaked, ops.Details ?? string.Empty);
}
}
[Fact]
public async Task Dismissal_IsCounted_ButAnnouncesNothing()
{
var h = new Harness();
var error = await h.Submit(null, null, dismissed: true);
Assert.Null(error);
var row = Assert.Single(h.Repo.Written);
Assert.False(row.Submitted);
Assert.Equal(1, row.DismissCount);
Assert.Empty(h.OpsEvents);
h.AssertNothingAnnounced();
}
[Fact]
public async Task AnswerOnARatedRow_ChangesNothing_AndAnnouncesNothing()
{
// A second tab answering after the rating: the row is final, so nothing is written or pinged twice.
var h = new Harness(current: new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = DisplayName,
Rating = 5, Submitted = true, InteractedAt = Now.AddMinutes(-5),
});
var error = await h.Submit(1, "second tab");
Assert.Null(error);
Assert.Empty(h.Repo.Written);
Assert.Empty(h.OpsEvents);
h.AssertNothingAnnounced();
}
[Theory]
[InlineData(0)]
[InlineData(6)]
[InlineData(null)]
public async Task InvalidRating_IsRejected_BeforeAnythingIsWritten(int? rating)
{
var h = new Harness();
var error = await h.Submit(rating, Comment);
Assert.Equal("Rating must be between 1 and 5", error);
Assert.False(h.Repo.UpdateCalled);
Assert.Empty(h.OpsEvents);
h.AssertNothingAnnounced();
}
[Fact]
public async Task UnknownTenant_KeepsTheGuidLabel_AndAnEmptyCommentCountsZero()
{
var h = new Harness(configExists: false);
await h.Submit(5, " ");
var ops = Assert.Single(h.OpsEvents);
Assert.Equal($"Feedback rating 5/5 received from {NormalizedTenantId}", ops.Message);
using var details = JsonDocument.Parse(ops.Details!);
Assert.Equal(0, details.RootElement.GetProperty("commentLength").GetInt32());
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", $"{Upn} ({NormalizedTenantId}) · 5/5", Constants.PortalUserFeedbackPath), Times.Once);
}
[Fact]
public async Task OpsEventStorageOutage_DoesNotFailTheRating()
{
var h = new Harness(opsEventThrows: true);
var error = await h.Submit(3, Comment);
Assert.Null(error);
Assert.Single(h.Repo.Written);
Assert.Empty(h.OpsEvents);
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", It.IsAny<string>(), Constants.PortalUserFeedbackPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
}
[Fact]
public async Task OverlongComment_IsCutToTheLimit_AndTheAlertCountsTheStoredLength()
{
var max = Constants.SubmissionLimits.FeedbackTextMaxChars;
var h = new Harness();
await h.Submit(4, new string('x', max + 50));
var row = Assert.Single(h.Repo.Written);
Assert.Equal(max, row.Comment!.Length);
using var details = JsonDocument.Parse(Assert.Single(h.OpsEvents).Details!);
Assert.Equal(max, details.RootElement.GetProperty("commentLength").GetInt32());
}
private sealed class Harness
{
public FeedbackFunction Sut { get; }
public FakeFeedbackRepository Repo { get; }
public List<OpsEventEntry> OpsEvents { get; } = new();
public Mock<GlobalNotificationService> Bell { get; }
public Mock<TelegramNotificationService> Telegram { get; }
public Harness(FeedbackEntry? current = null, bool configExists = true, bool opsEventThrows = false)
{
Repo = new FakeFeedbackRepository(current);
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
var tenantConfig = new Mock<TenantConfigurationService>(
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), Mock.Of<IMemoryCache>())
{ CallBase = false };
var config = TenantConfiguration.CreateDefault(NormalizedTenantId);
config.DomainName = "contoso.invalid";
tenantConfig.Setup(c => c.TryGetConfigurationAsync(NormalizedTenantId))
.ReturnsAsync((config, configExists));
Bell = new Mock<GlobalNotificationService>(
Mock.Of<INotificationRepository>(), new FakeSignalRNotificationService(),
Mock.Of<ILogger<GlobalNotificationService>>())
{ CallBase = false };
Telegram = new Mock<TelegramNotificationService>(
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
{ CallBase = false };
var adminConfig = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
{ CallBase = false };
var tenantAdmins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
Sut = new FeedbackFunction(
NullLogger<FeedbackFunction>.Instance, tenantConfig.Object, adminConfig.Object, tenantAdmins.Object,
Telegram.Object, Mock.Of<ISessionRepository>(), Repo,
BuildOpsEventService(adminConfig.Object, opsEventThrows), Bell.Object);
}
public Task<string?> Submit(int? rating, string? comment, bool dismissed = false)
=> Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName,
new FeedbackRequest { Rating = rating, Comment = comment, Dismissed = dismissed }, Now);
public void AssertNothingAnnounced()
{
Bell.Verify(b => b.CreateNotificationAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Never);
Telegram.Verify(t => t.SendFeedbackAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()), Times.Never);
}
/// <summary>A real OpsEventService over a capturing repository, so the actual record method runs.</summary>
private OpsEventService BuildOpsEventService(AdminConfigurationService adminConfig, bool opsEventThrows)
{
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e =>
{
if (opsEventThrows)
throw new InvalidOperationException("simulated OpsEvent storage outage");
OpsEvents.Add(e);
})
.Returns(Task.CompletedTask);
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig));
}
}
/// <summary>In-memory feedback repo holding one person's bubble row; only the in-app members are expected.</summary>
private sealed class FakeFeedbackRepository : IFeedbackRepository
{
private FeedbackEntry? _current;
public FakeFeedbackRepository(FeedbackEntry? current) => _current = current;
public List<FeedbackEntry> Written { get; } = new();
public bool UpdateCalled { get; private set; }
public Task<FeedbackEntry?> UpdateInAppFeedbackAsync(string upn, Func<FeedbackEntry?, FeedbackEntry?> decide)
{
UpdateCalled = true;
var next = decide(_current);
if (next != null)
{
next.Type = FeedbackEntryType.InApp;
_current = next;
Written.Add(next);
}
return Task.FromResult(next);
}
public Task<FeedbackEntry?> GetInAppFeedbackAsync(string upn) => throw new NotSupportedException();
public Task<FeedbackEntry?> GetOffboardingFeedbackAsync(string historyRowKey) => throw new NotSupportedException();
public Task SaveOffboardingFeedbackAsync(FeedbackEntry entry) => throw new NotSupportedException();
public Task SaveGeneralFeedbackAsync(FeedbackEntry entry) => throw new NotSupportedException();
public Task<int> CountGeneralFeedbackSinceAsync(string upn, DateTime sinceUtc) => throw new NotSupportedException();
public Task<List<FeedbackEntry>> GetAllAsync() => throw new NotSupportedException();
}
}
@@ -24,6 +24,8 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
private readonly TelegramNotificationService _telegramNotificationService;
private readonly ISessionRepository _sessionRepo;
private readonly IFeedbackRepository _feedbackRepo;
private readonly OpsEventService _opsEvents;
private readonly GlobalNotificationService _globalNotifications;
public FeedbackFunction(
ILogger<FeedbackFunction> logger,
@@ -32,7 +34,9 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
TenantAdminsService tenantAdminsService,
TelegramNotificationService telegramNotificationService,
ISessionRepository sessionRepo,
IFeedbackRepository feedbackRepo)
IFeedbackRepository feedbackRepo,
OpsEventService opsEvents,
GlobalNotificationService globalNotifications)
{
_logger = logger;
_tenantConfigService = tenantConfigService;
@@ -41,6 +45,8 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
_telegramNotificationService = telegramNotificationService;
_sessionRepo = sessionRepo;
_feedbackRepo = feedbackRepo;
_opsEvents = opsEvents;
_globalNotifications = globalNotifications;
}
/// <summary>
@@ -122,9 +128,9 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
}
/// <summary>
/// Records one answer to the rating bubble (<see cref="FeedbackPromptRules"/>).
/// A rating is stored once per person and pinged to Telegram; a dismissal is counted.
/// After a rating the person's row is final: further answers change nothing.
/// Records one answer to the rating bubble (<see cref="FeedbackPromptRules"/>): a rating is
/// stored once per person, a dismissal is counted. After a rating the person's row is final:
/// further answers change nothing.
/// </summary>
[Function("SubmitFeedback")]
public async Task<HttpResponseData> Submit(
@@ -140,34 +146,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
// Request body size limit (1 MB)
var read = await req.ReadAsync<FeedbackRequest>(1_048_576);
if (read.Error != null) return read.Error;
var body = read.Value!;
// Validate rating
if (!body.Dismissed && body.Rating is not (>= 1 and <= 5))
{
return await req.BadRequestAsync("Rating must be between 1 and 5");
}
// Trim comment to the shared feedback limit (Azure Tables caps a single property
// at 64 KB, so this stays well within).
var comment = body.Comment?.Trim();
if (comment?.Length > Constants.SubmissionLimits.FeedbackTextMaxChars)
comment = comment.Substring(0, Constants.SubmissionLimits.FeedbackTextMaxChars);
var answer = new FeedbackAnswer(upn, tenantId, displayName, body.Dismissed, body.Rating, comment);
var stored = await _feedbackRepo.UpdateInAppFeedbackAsync(
upn, current => FeedbackPromptRules.Apply(current, answer, DateTime.UtcNow));
// Telegram only for a rating that was actually recorded, fire-and-forget.
if (stored is { Submitted: true } && stored.Rating is int rating)
{
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, rating, comment);
}
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
stored == null ? "unchanged" : body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
return await WriteJson(req, new SuccessOnlyResponse { Success = true });
var error = await ProcessSubmitAsync(tenantId, upn, displayName, read.Value!, DateTime.UtcNow);
return error != null
? await req.BadRequestAsync(error)
: await WriteJson(req, new SuccessOnlyResponse { Success = true });
}
catch (UnauthorizedAccessException)
{
@@ -179,6 +162,67 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
}
}
/// <summary>
/// The answer decision without the HTTP shell, so tests drive every branch directly (same
/// pattern as <c>SubmitGeneralFeedbackFunction.ProcessSubmitAsync</c>). Returns the validation
/// error, or null once the answer is handled.
/// </summary>
internal async Task<string?> ProcessSubmitAsync(
string tenantId, string upn, string displayName, FeedbackRequest body, DateTime nowUtc)
{
if (!body.Dismissed && body.Rating is not (>= 1 and <= 5))
return "Rating must be between 1 and 5";
// Trim comment to the shared feedback limit (Azure Tables caps a single property
// at 64 KB, so this stays well within).
var comment = body.Comment?.Trim();
if (comment?.Length > Constants.SubmissionLimits.FeedbackTextMaxChars)
comment = comment.Substring(0, Constants.SubmissionLimits.FeedbackTextMaxChars);
var answer = new FeedbackAnswer(upn, tenantId, displayName, body.Dismissed, body.Rating, comment);
var stored = await _feedbackRepo.UpdateInAppFeedbackAsync(
upn, current => FeedbackPromptRules.Apply(current, answer, nowUtc));
// Only a rating that was actually recorded is announced — at most once per person.
if (stored is { Submitted: true } && stored.Rating is int rating)
await AnnounceRatingAsync(tenantId, upn, displayName, rating, comment);
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
stored == null ? "unchanged" : body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
return null;
}
/// <summary>
/// The signals of a recorded rating, all best effort: a stored rating never fails because of
/// them. The ops event carries the stars and the comment length only, so an alert channel
/// receives no personal data; the Telegram ping keeps sender and comment until it is retired.
/// </summary>
private async Task AnnounceRatingAsync(string tenantId, string upn, string displayName, int rating, string? comment)
{
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, rating, comment);
var normalizedTenantId = tenantId.ToLowerInvariant();
// The domain makes the alert and the bell readable. The lookup never creates a
// configuration row; an unknown tenant simply keeps its GUID label.
var (config, exists) = await _tenantConfigService.TryGetConfigurationAsync(normalizedTenantId);
var domainName = exists && !string.IsNullOrWhiteSpace(config.DomainName) ? config.DomainName : null;
try
{
await _opsEvents.RecordFeedbackRatingReceivedAsync(
normalizedTenantId, upn, domainName, rating, comment?.Length ?? 0);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "FeedbackRatingReceived ops event failed for tenant {TenantId}", normalizedTenantId);
}
_ = _globalNotifications.CreateNotificationAsync(
"feedback", "New Rating", $"{upn} ({domainName ?? normalizedTenantId}) · {rating}/5",
href: Constants.PortalUserFeedbackPath);
}
/// <summary>
/// Returns all feedback entries for the Global Admin dashboard.
/// </summary>
@@ -28,7 +28,6 @@ namespace AutopilotMonitor.Functions.Functions.Feedback;
public class SubmitGeneralFeedbackFunction
{
private const long MaxBodyBytes = 65_536;
private const string ReportsPath = "/admin/reports/user-feedback";
private readonly ILogger<SubmitGeneralFeedbackFunction> _logger;
private readonly IFeedbackRepository _feedbackRepo;
@@ -152,7 +151,7 @@ public class SubmitGeneralFeedbackFunction
}
_ = _globalNotifications.CreateNotificationAsync(
"feedback", "New Feedback", $"{entry.Upn} ({domainName ?? normalizedTenantId})", href: ReportsPath);
"feedback", "New Feedback", $"{entry.Upn} ({domainName ?? normalizedTenantId})", href: Constants.PortalUserFeedbackPath);
_logger.LogInformation("General feedback {FeedbackId} stored for tenant {TenantId} ({Length} chars)",
entry.FeedbackId, normalizedTenantId, message.Length);
@@ -278,10 +278,12 @@ public static class EndpointAccessPolicyCatalog
// as an external identity, which does not activate a tenant.
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser),
// ── MemberRead (Admin + Operator, later + Viewer) ───────────────
// The rating bubble only ever asks members (feedback/status), and a rating raises an operator
// alert — so, like feedback/general, the answer route admits members only.
new("POST", "feedback", EndpointPolicy.MemberRead),
// Help-menu feedback: every member may write to the team, a roleless caller may not (the
// in-app rating route above stays AuthenticatedUser). The function caps it per user and day.
new("POST", "feedback/general", EndpointPolicy.MemberRead),
@@ -656,9 +656,7 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordOffboardingFeedbackReceivedAsync(
string tenantId, string submittedBy, string? domainName, string historyRowKey)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName)
? tenantId
: $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
var details = new Dictionary<string, object?>
{
@@ -680,9 +678,7 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordFeedbackReceivedAsync(
string tenantId, string submittedBy, string? domainName, string feedbackId, int length, bool hasContactEmail)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName)
? tenantId
: $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
var details = new Dictionary<string, object?>
{
@@ -690,7 +686,7 @@ namespace AutopilotMonitor.Functions.Services
["feedbackId"] = feedbackId,
["length"] = length,
["hasContactEmail"] = hasContactEmail,
["portalPath"] = "/admin/reports/user-feedback",
["portalPath"] = Constants.PortalUserFeedbackPath,
};
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FeedbackReceived, OpsEventSeverity.Info,
@@ -698,11 +694,31 @@ namespace AutopilotMonitor.Functions.Services
tenantId, submittedBy, details);
}
/// <summary>
/// Fired when a member rates the portal in the feedback bubble — at most once per person, never
/// for a dismissal. Info. Like <see cref="RecordFeedbackReceivedAsync"/>, message and details carry
/// neither the comment nor the sender: only the stars and the comment length; the comment is read
/// in the portal (User Feedback).
/// </summary>
public Task RecordFeedbackRatingReceivedAsync(
string tenantId, string submittedBy, string? domainName, int rating, int commentLength)
{
var details = new Dictionary<string, object?>
{
["domainName"] = domainName,
["rating"] = rating,
["commentLength"] = commentLength,
["portalPath"] = Constants.PortalUserFeedbackPath,
};
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FeedbackRatingReceived, OpsEventSeverity.Info,
$"Feedback rating {rating}/5 received from {TenantLabel(tenantId, domainName)}",
tenantId, submittedBy, details);
}
public Task RecordTenantOffboardedAsync(string tenantId, string performedBy, Dictionary<string, int> deletedCounts, string? domainName = null)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName)
? tenantId
: $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
// Wrap deletedCounts + domainName so the OpsEvents details panel still surfaces the
// per-table counts AND the domain (needed because Table Storage is gone by emit time).
@@ -727,9 +743,7 @@ namespace AutopilotMonitor.Functions.Services
string tenantId, string performedBy, string failedPhase, string errorMessage,
int retryCount, string? domainName = null)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName)
? tenantId
: $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantOffboardingFailed, OpsEventSeverity.Error,
$"Tenant {tenantLabel} offboarding failed at phase '{failedPhase}': {errorMessage}",
@@ -746,7 +760,7 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public Task RecordTenantAutoApprovedAsync(string tenantId, string? domainName, string signupUpn)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantAutoApproved, OpsEventSeverity.Info,
$"Tenant {tenantLabel} was auto-activated after signup (signup by {signupUpn})",
tenantId, "System (auto-approve)", new { domainName, signupUpn });
@@ -763,7 +777,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>The provider accepted the activation welcome mail. Info-tier confirmation.</summary>
public Task RecordWelcomeEmailSentAsync(string tenantId, string? domainName, string toEmail, string addressSource)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.WelcomeEmailSent, OpsEventSeverity.Info,
$"Welcome email sent to {toEmail} for tenant {tenantLabel} (address from {addressSource})",
tenantId, "System.Activation", new { domainName, toEmail, addressSource });
@@ -776,7 +790,7 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public Task RecordWelcomeEmailSkippedAsync(string tenantId, string? domainName, string reason)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.WelcomeEmailSkipped, OpsEventSeverity.Warning,
$"No welcome email for tenant {tenantLabel} — {reason}",
tenantId, "System.Activation", new { domainName, reason });
@@ -788,7 +802,7 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public Task RecordWelcomeEmailFailedAsync(string tenantId, string? domainName, string toEmail, string reason)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.WelcomeEmailFailed, OpsEventSeverity.Error,
$"Welcome email to {toEmail} failed for tenant {tenantLabel} — {reason}",
tenantId, "System.Activation", new { domainName, toEmail, reason });
@@ -804,7 +818,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>The provider accepted the farewell mail. Info-tier confirmation.</summary>
public Task RecordFarewellEmailSentAsync(string tenantId, string? domainName, string toEmail)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailSent, OpsEventSeverity.Info,
$"Farewell email sent to {toEmail} for offboarded tenant {tenantLabel}",
tenantId, "System.Offboarding", new { domainName, toEmail });
@@ -817,7 +831,7 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public Task RecordFarewellEmailSkippedAsync(string tenantId, string? domainName, string reason)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailSkipped, OpsEventSeverity.Warning,
$"No farewell email for offboarded tenant {tenantLabel} — {reason}",
tenantId, "System.Offboarding", new { domainName, reason });
@@ -829,7 +843,7 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public Task RecordFarewellEmailFailedAsync(string tenantId, string? domainName, string toEmail, string reason)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailFailed, OpsEventSeverity.Error,
$"Farewell email to {toEmail} failed for offboarded tenant {tenantLabel} — {reason}",
tenantId, "System.Offboarding", new { domainName, toEmail, reason });
@@ -850,7 +864,7 @@ namespace AutopilotMonitor.Functions.Services
string tenantId, string? domainName, string? contactEmail, string? companyName,
DateTime? trialStartedUtc, DateTime? trialExpiresUtc, string grantedBy, bool selfService)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
var expiryNote = trialExpiresUtc is DateTime expiry
? $"until {expiry:yyyy-MM-dd HH:mm}Z"
: "with no end date set";
@@ -864,7 +878,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>Heads-up: a Pro trial ends within the next few days. Info-tier visibility signal.</summary>
public Task RecordTenantTrialExpiringAsync(string tenantId, string? domainName, DateTime trialExpiresUtc, int daysLeft)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantTrialExpiring, OpsEventSeverity.Info,
$"Pro trial for tenant {tenantLabel} expires in {daysLeft} day(s) ({trialExpiresUtc:yyyy-MM-dd HH:mm}Z)",
tenantId, "System.TrialSweep", new { domainName, trialExpiresUtc, daysLeft });
@@ -877,7 +891,7 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public Task RecordTenantTrialExpiredAsync(string tenantId, string? domainName, DateTime trialExpiredUtc)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantTrialExpired, OpsEventSeverity.Warning,
$"Pro trial for tenant {tenantLabel} expired ({trialExpiredUtc:yyyy-MM-dd HH:mm}Z) — tenant is now Community",
tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc });
@@ -897,7 +911,7 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordTenantPlanDowngradedAsync(
string tenantId, string? domainName, string caller, DateTime? retentionGraceEndsUtc, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
var graceNote = retentionGraceEndsUtc is DateTime graceEnd
? $"retention grace until {graceEnd:yyyy-MM-dd HH:mm}Z"
: "no retention grace applicable";
@@ -915,7 +929,7 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordTenantRetentionGraceExpiringAsync(
string tenantId, string? domainName, DateTime graceEndsUtc, int daysLeft, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantRetentionGraceExpiring, OpsEventSeverity.Warning,
$"Retention grace for downgraded tenant {tenantLabel} ends in {daysLeft} day(s) ({graceEndsUtc:yyyy-MM-dd HH:mm}Z) — " +
$"data older than the Community cap will then be deleted (stored retention {storedRetentionDays}d)",
@@ -930,7 +944,7 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordTenantRetentionGraceEndedAsync(
string tenantId, string? domainName, DateTime graceEndedUtc, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantRetentionGraceEnded, OpsEventSeverity.Warning,
$"Retention grace for downgraded tenant {tenantLabel} ended ({graceEndedUtc:yyyy-MM-dd HH:mm}Z) — " +
$"the Community retention cap is now enforced and older data will be deleted (stored retention {storedRetentionDays}d)",
@@ -1046,7 +1060,7 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordDiagnosticsUploadConfigChangedAsync(
string tenantId, string? domainName, DiagnosticsUploadConfigChange change, string changedBy, string source)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var tenantLabel = TenantLabel(tenantId, domainName);
var quickConfig = string.Equals(source, Functions.Config.PatchTenantConfigurationFieldsFunction.CollectLogsSource, StringComparison.Ordinal);
if (change.Enabled && quickConfig)
{
@@ -1314,6 +1328,10 @@ namespace AutopilotMonitor.Functions.Services
// ── Core write method ──────────────────────────────────────────────────
/// <summary>How a message names a tenant: "domain (tenantId)", or the bare id without a domain.</summary>
private static string TenantLabel(string tenantId, string? domainName)
=> string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
private async Task WriteAsync(string category, string eventType, string severity,
string message, string? tenantId, string? userId, object? details)
{
@@ -221,7 +221,7 @@ namespace AutopilotMonitor.Functions.Services
/// Sends a Telegram notification when a user submits feedback via the in-app feedback bubble.
/// Best-effort — silently no-ops if the webhook URL is not configured.
/// </summary>
public async Task SendFeedbackAsync(string tenantId, string upn, string displayName, int rating, string? comment)
public virtual async Task SendFeedbackAsync(string tenantId, string upn, string displayName, int rating, string? comment)
{
try
{
@@ -111,6 +111,7 @@ export const OPS_EVENT_TYPES = [
"McpServicePrincipalFirstSeen",
"OffboardingFeedbackReceived",
"FeedbackReceived",
"FeedbackRatingReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
@@ -137,6 +137,9 @@ namespace AutopilotMonitor.Shared
/// <summary>The tenant's hardware whitelist settings, which also list the rejected models.</summary>
public const string PortalHardwareWhitelistPath = "/settings/tenant/hardware-whitelist";
/// <summary>The Global Admin list of every feedback kind (help menu, rating bubble, offboarding).</summary>
public const string PortalUserFeedbackPath = "/admin/reports/user-feedback";
/// <summary>
/// The pairing link shown as QR code and typed into the receiver app. The code travels in the
/// fragment (never reaches a server log); the trailing slash matches the static export.
@@ -109,6 +109,12 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
public const string FeedbackReceived = "FeedbackReceived";
/// <summary>
/// A member rated the portal in the feedback bubble (<c>POST feedback</c>) — once per person, a
/// dismissal raises nothing. Info. Message and details carry the stars and the comment length,
/// never the comment or the sender; the comment is read in the portal (User Feedback).
/// </summary>
public const string FeedbackRatingReceived = "FeedbackRatingReceived";
/// <summary>
/// A new tenant signed up: its first sign-in seeded the domain (<c>auth/me</c>). Info. Recorded
/// next to the direct Telegram ping until that is retired (plan push-relay, Phase 0); message and
/// details carry no personal data — the Tenants page has the rest.
@@ -194,7 +200,7 @@ namespace AutopilotMonitor.Shared.DataAccess
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles,
MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, MaintenanceStarted, MaintenanceSkippedLocked, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, OrphanSweepCompleted, OrphanSweepFailed, OrphanSweepSkippedLocked, OrphanReconcileCompleted, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift, SoftwareInventoryCounterWriteFailed,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied, McpServicePrincipalFirstSeen,
OffboardingFeedbackReceived, FeedbackReceived, TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted, RuleSubmissionReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
OffboardingFeedbackReceived, FeedbackReceived, FeedbackRatingReceived, TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted, RuleSubmissionReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, TelemetryItemsRejected,
SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted,
AzureMonitorAlert, PushDeliveryFailed, PushEndpointRefused,
@@ -7,11 +7,13 @@ import { MAX_NOTIFICATION_CHANNELS, type NotificationChannel } from "@/app/setti
import { toggleChannelBinding } from "./opsChannelRouting";
import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } from "./excessiveEventAutoAction";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { SearchInput } from "@/components/SearchInput";
import { filterOpsEventCatalog } from "./opsAlertRuleSearch";
import type { ChannelHealthView } from "@/lib/channelHealth";
/** Destination sentence of a platform Push channel (ChannelEditor shows it in place of the URL field). */
const PUSH_DESTINATION_HINT_OPS =
"Delivers to the devices Global Administrators pair under Alerts › Push devices. The Payload option has no effect on Push.";
"Delivers to the devices Global Administrators pair under Alert Rules › Push devices. The Payload option has no effect on Push.";
// All known ops event types grouped by category
const OPS_EVENT_TYPES: Record<string, string[]> = {
@@ -170,6 +172,9 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// FeedbackReceived — a member sent feedback from the help menu. Info-tier; carries no text
// and no sender, the feedback itself is read under Reports → User Feedback.
"FeedbackReceived",
// FeedbackRatingReceived — a member rated the portal in the feedback bubble (once per person).
// Info-tier; carries the stars and the comment length, never the comment or the sender.
"FeedbackRatingReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
@@ -282,6 +287,8 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
],
};
const OPS_EVENT_TYPE_COUNT = Object.values(OPS_EVENT_TYPES).flat().length;
const SEVERITIES = ["Info", "Warning", "Error", "Critical"];
const CATEGORY_COLORS: Record<string, string> = {
@@ -348,6 +355,7 @@ export function OpsAlertRulesSection({
const [autoActionMode, setAutoActionMode] = useState<AutoActionMode>(autoActionModeProp);
const [autoActionThreshold, setAutoActionThreshold] = useState(autoActionThresholdProp);
const [autoActionDuration, setAutoActionDuration] = useState(autoActionDurationProp);
const [ruleQuery, setRuleQuery] = useState("");
// Re-seed the local draft whenever a synced prop changes (config load/save),
// replicating the previous sync effect's dependency-array comparison via the
@@ -409,6 +417,11 @@ export function OpsAlertRulesSection({
const enabledRulesCount = rules.filter(r => r.enabled).length;
const enabledProviders = channels.filter(c => c.enabled).length;
// The search narrows the rows shown; `rules` keeps every rule, so a save never drops a hidden one.
const trimmedQuery = ruleQuery.trim();
const visibleCatalog = filterOpsEventCatalog(OPS_EVENT_TYPES, ruleQuery);
const shownCount = visibleCatalog.reduce((n, [, eventTypes]) => n + eventTypes.length, 0);
return (
<div className="space-y-6">
{/* Alert Rules */}
@@ -418,6 +431,16 @@ export function OpsAlertRulesSection({
iconPath="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
title="Alert Rules"
subtitle={`Select which ops events trigger alerts. ${enabledRulesCount} rule(s) active, ${enabledProviders} provider(s) enabled.`}
trailing={
<SearchInput
value={ruleQuery}
onChange={setRuleQuery}
placeholder="Search rules..."
ariaLabel="Search alert rules"
name="alert-rule-search"
tone="amber"
/>
}
/>
<div className="p-6">
{loadingConfig ? (
@@ -427,7 +450,14 @@ export function OpsAlertRulesSection({
</div>
) : (
<div className="space-y-6">
{Object.entries(OPS_EVENT_TYPES).map(([category, eventTypes]) => (
{trimmedQuery !== "" && (
<p className="text-xs text-amber-800 dark:text-amber-200">
{shownCount === 0
? `No alert rules match "${trimmedQuery}".`
: `${shownCount} of ${OPS_EVENT_TYPE_COUNT} rules match.`}
</p>
)}
{visibleCatalog.map(([category, eventTypes]) => (
<div key={category}>
<h3 className={`text-sm font-semibold uppercase tracking-wide mb-2 ${CATEGORY_COLORS[category] ?? "text-gray-700 dark:text-gray-300"}`}>
{category}
@@ -11,6 +11,7 @@ import type { CursorPage } from "@/lib/cursorPager";
import { useCursorPager } from "@/hooks/useCursorPager";
import { extractSessionId, extractPortalPath, buildAutoReason } from "./opsEventSessionHelpers";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { SearchInput } from "@/components/SearchInput";
import type { OpsEventEntry, OpsEventListResponse } from "@/lib/generated/wire-types.generated";
import { ModalPortal } from "@/components/ModalPortal";
import { CursorPagerFooter } from "./CursorPagerFooter";
@@ -301,28 +302,14 @@ function OpsEventsSectionInner({
{/* Search & Category Filter */}
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-wrap">
<div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search this page..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-52 pl-8 pr-7 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-slate-500 focus:border-slate-500"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search this page..."
ariaLabel="Search the ops events on this page"
name="ops-event-search"
tone="slate"
/>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Filter:</span>
<button
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { filterOpsEventCatalog } from "../opsAlertRuleSearch";
const CATALOG = {
Consent: ["ConsentFlowStarted", "ConsentFlowFailed"],
Tenant: ["OffboardingFeedbackReceived", "FeedbackReceived", "FeedbackRatingReceived", "TenantSignup"],
Platform: ["PushDeliveryFailed", "PushEndpointRefused"],
};
describe("filterOpsEventCatalog", () => {
it("keeps the whole catalog for an empty or blank query", () => {
expect(filterOpsEventCatalog(CATALOG, "")).toEqual(Object.entries(CATALOG));
expect(filterOpsEventCatalog(CATALOG, " \t ")).toEqual(Object.entries(CATALOG));
});
it("finds every type that contains the term, ignoring case", () => {
expect(filterOpsEventCatalog(CATALOG, "FEEDBACK")).toEqual([
["Tenant", ["OffboardingFeedbackReceived", "FeedbackReceived", "FeedbackRatingReceived"]],
]);
});
it("requires every term, so words narrow the way the type name reads", () => {
expect(filterOpsEventCatalog(CATALOG, "feedback rating")).toEqual([["Tenant", ["FeedbackRatingReceived"]]]);
expect(filterOpsEventCatalog(CATALOG, "push failed")).toEqual([["Platform", ["PushDeliveryFailed"]]]);
});
it("keeps a whole group when the term names its category", () => {
expect(filterOpsEventCatalog(CATALOG, "consent")).toEqual([["Consent", ["ConsentFlowStarted", "ConsentFlowFailed"]]]);
expect(filterOpsEventCatalog(CATALOG, "platform refused")).toEqual([["Platform", ["PushEndpointRefused"]]]);
});
it("drops categories without a match and returns nothing when no type matches", () => {
expect(filterOpsEventCatalog(CATALOG, "failed")).toEqual([
["Consent", ["ConsentFlowFailed"]],
["Platform", ["PushDeliveryFailed"]],
]);
expect(filterOpsEventCatalog(CATALOG, "telegram")).toEqual([]);
});
});
@@ -0,0 +1,21 @@
/**
* The alert-rule catalog narrowed to a search: every whitespace-separated term must occur,
* case-insensitively, in "{category} {eventType}", so "feedback rating" finds FeedbackRatingReceived
* and "consent" keeps the whole Consent group. Categories without a match drop out; an empty query
* keeps everything. It narrows what is shown, never the rules that are saved.
*/
export function filterOpsEventCatalog(
catalog: Readonly<Record<string, readonly string[]>>,
query: string,
): [string, string[]][] {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
return Object.entries(catalog)
.map(([category, eventTypes]): [string, string[]] => [
category,
eventTypes.filter((eventType) => {
const haystack = `${category} ${eventType}`.toLowerCase();
return terms.every((term) => haystack.includes(term));
}),
])
.filter(([, eventTypes]) => eventTypes.length > 0);
}
@@ -0,0 +1,57 @@
"use client";
/** Focus ring in the colour family of the card the field sits in. */
const FOCUS_TONES = {
slate: "focus:ring-slate-500 focus:border-slate-500",
amber: "focus:ring-amber-500 focus:border-amber-500",
} as const;
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
/** The accessible name; a placeholder is not one. */
ariaLabel: string;
name: string;
tone: keyof typeof FOCUS_TONES;
}
/**
* The compact search field of the admin cards: a magnifier, a clear button while a query is set,
* and Escape clears as well.
*/
export function SearchInput({ value, onChange, placeholder, ariaLabel, name, tone }: SearchInputProps) {
return (
<div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
name={name}
placeholder={placeholder}
aria-label={ariaLabel}
autoComplete="off"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && value) onChange("");
}}
className={`w-52 pl-8 pr-7 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 ${FOCUS_TONES[tone]}`}
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
title="Clear search"
aria-label="Clear search"
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}
@@ -0,0 +1,79 @@
/**
* Every section page is reachable from the sidebar, and every sidebar link into a section route
* names a real section.
*
* A `[section]` route takes its ids from the `*NavSections.ts` registry beside it
* (`generateStaticParams`), while the sidebar (lib/globalNavConfig.tsx) lists its links by hand —
* two registries, neither derived from the other. A section missing from the sidebar is reachable
* only by typing its URL (Settings › Alerts was, until the Ops › Alert Rules entry); a sidebar link
* to an id the registry does not know compiles (`route()` validates the prefix, not the section)
* and is a 404 on the static export.
*/
import { describe, expect, it } from "vitest";
import { readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { DASHBOARD_ITEM, EXPANDABLE_NAV_GROUPS, NAV_GROUPS, REGULAR_USER_ITEMS } from "../globalNavConfig";
import { METRICS_NAV_SECTIONS } from "@/app/admin/metrics/metricsNavSections";
import { REPORTS_NAV_SECTIONS } from "@/app/admin/reports/reportsNavSections";
import { SECURITY_NAV_SECTIONS } from "@/app/admin/security/securityNavSections";
import { SETTINGS_NAV_SECTIONS } from "@/app/admin/settings/settingsNavSections";
import { TENANTS_NAV_SECTIONS } from "@/app/admin/tenants/tenantsNavSections";
import { AGENT_NAV_SECTIONS } from "@/app/settings/agent/agentNavSections";
import { MANAGEMENT_NAV_SECTIONS } from "@/app/settings/management/managementNavSections";
import { REPORTING_NAV_SECTIONS } from "@/app/settings/reporting/reportingNavSections";
import { TENANT_NAV_SECTIONS } from "@/app/settings/tenant/tenantNavSections";
/** Route base (the folder the registry sits in) → its section ids. */
const REGISTRIES: Record<string, readonly { id: string }[]> = {
"/admin/metrics": METRICS_NAV_SECTIONS,
"/admin/reports": REPORTS_NAV_SECTIONS,
"/admin/security": SECURITY_NAV_SECTIONS,
"/admin/settings": SETTINGS_NAV_SECTIONS,
"/admin/tenants": TENANTS_NAV_SECTIONS,
"/settings/agent": AGENT_NAV_SECTIONS,
"/settings/management": MANAGEMENT_NAV_SECTIONS,
"/settings/reporting": REPORTING_NAV_SECTIONS,
"/settings/tenant": TENANT_NAV_SECTIONS,
};
const APP_DIR = join(__dirname, "..", "..", "app");
/** The route base of every `*NavSections.ts` under app/ — the folder it sits in. */
function registryBases(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) registryBases(full, acc);
else if (entry.name.endsWith("NavSections.ts")) acc.push("/" + relative(APP_DIR, dir).split(sep).join("/"));
}
return acc;
}
const SIDEBAR_HREFS: string[] = [
DASHBOARD_ITEM.href,
...REGULAR_USER_ITEMS.map((i) => i.href),
...NAV_GROUPS.flatMap((g) => g.items.map((i) => i.href)),
...EXPANDABLE_NAV_GROUPS.flatMap((g) => g.items.flatMap((i) => i.items.map((s) => s.href))),
];
describe("section pages ↔ sidebar", () => {
it("covers every *NavSections.ts registry under app/", () => {
expect(registryBases(APP_DIR).sort()).toEqual(Object.keys(REGISTRIES).sort());
});
it("links every section from the sidebar", () => {
const unlinked = Object.entries(REGISTRIES).flatMap(([base, sections]) =>
sections.map((s) => `${base}/${s.id}`).filter((href) => !SIDEBAR_HREFS.includes(href)),
);
expect(unlinked).toEqual([]);
});
it("links only registered sections", () => {
const unknown = SIDEBAR_HREFS.filter((href) => {
const base = Object.keys(REGISTRIES).find((b) => href.startsWith(`${b}/`));
if (!base) return false;
const id = href.slice(base.length + 1);
return !REGISTRIES[base].some((s) => s.id === id);
});
expect(unknown).toEqual([]);
});
});
@@ -656,6 +656,7 @@ export const SHARED_MANIFEST = {
"McpServicePrincipalFirstSeen",
"OffboardingFeedbackReceived",
"FeedbackReceived",
"FeedbackRatingReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
@@ -652,6 +652,7 @@
"McpServicePrincipalFirstSeen",
"OffboardingFeedbackReceived",
"FeedbackReceived",
"FeedbackRatingReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
@@ -305,10 +305,13 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
},
{
// Ops + Backups + Customs Archive are destructive platform operations — GA-only, hidden from a
// read-only Global Reader (routes are also gated via requireGlobalAdmin layouts).
// read-only Global Reader (routes are also gated via requireGlobalAdmin layouts). Alert Rules is
// the settings section /admin/settings/alerts; it lives only here, because a second entry under
// Settings would make that group auto-expand instead (first match wins).
id: "ga-ops", label: "Ops", icon: <WrenchIcon />, visibility: "globalAdminOnly",
items: [
{ id: "ga-maintenance", label: "Maintenance", href: "/admin/ops" },
{ id: "ga-alert-rules", label: "Alert Rules", href: route("/admin/settings/alerts") },
{ id: "ga-session-cleanup", label: "Session Cleanup", href: "/admin/ops/session-cleanup" },
{ id: "ga-customs-archive", label: "Customs Archive", href: "/admin/customs-archive" },
{ id: "ga-backups", label: "Backups", href: "/admin/backups" },