diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs index b96e06c2..0e17d555 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs @@ -208,6 +208,20 @@ public class EndpointPolicyCatalogCompletenessTests Assert.Equal(TenantScoping.Jwt, entry.TenantScoping); } + /// + /// 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. + /// + [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); + } + /// /// Unregistered routes must return null (fail-closed). /// diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FeedbackFunctionSubmitTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FeedbackFunctionSubmitTests.cs new file mode 100644 index 00000000..b5cac43c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FeedbackFunctionSubmitTests.cs @@ -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; + +/// +/// The rating bubble's answer route, driven through ProcessSubmitAsync (the HTTP shell only +/// reads the body and maps the validation error). Pins that a recorded rating, and nothing else, +/// raises FeedbackRatingReceived, the Global Admin bell and the Telegram ping, and that the +/// alert carries neither the comment nor the sender. +/// +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(), 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 OpsEvents { get; } = new(); + public Mock Bell { get; } + public Mock Telegram { get; } + + public Harness(FeedbackEntry? current = null, bool configExists = true, bool opsEventThrows = false) + { + Repo = new FakeFeedbackRepository(current); + var configRepo = Mock.Of(); + var cache = new MemoryCache(new MemoryCacheOptions()); + + var tenantConfig = new Mock( + configRepo, Mock.Of>(), Mock.Of()) + { CallBase = false }; + var config = TenantConfiguration.CreateDefault(NormalizedTenantId); + config.DomainName = "contoso.invalid"; + tenantConfig.Setup(c => c.TryGetConfigurationAsync(NormalizedTenantId)) + .ReturnsAsync((config, configExists)); + + Bell = new Mock( + Mock.Of(), new FakeSignalRNotificationService(), + Mock.Of>()) + { CallBase = false }; + Telegram = new Mock( + new HttpClient(), configRepo, Mock.Of>()) + { CallBase = false }; + + var adminConfig = new Mock( + configRepo, NullLogger.Instance, cache) + { CallBase = false }; + var tenantAdmins = new Mock( + Mock.Of(), cache, Mock.Of>()) + { CallBase = false }; + + Sut = new FeedbackFunction( + NullLogger.Instance, tenantConfig.Object, adminConfig.Object, tenantAdmins.Object, + Telegram.Object, Mock.Of(), Repo, + BuildOpsEventService(adminConfig.Object, opsEventThrows), Bell.Object); + } + + public Task 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(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Telegram.Verify(t => t.SendFeedbackAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// A real OpsEventService over a capturing repository, so the actual record method runs. + private OpsEventService BuildOpsEventService(AdminConfigurationService adminConfig, bool opsEventThrows) + { + var opsRepo = new Mock(); + opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(e => + { + if (opsEventThrows) + throw new InvalidOperationException("simulated OpsEvent storage outage"); + OpsEvents.Add(e); + }) + .Returns(Task.CompletedTask); + + return new OpsEventService(opsRepo.Object, NullLogger.Instance, + TestNotifications.InertOpsAlertDispatch(adminConfig)); + } + } + + /// In-memory feedback repo holding one person's bubble row; only the in-app members are expected. + private sealed class FakeFeedbackRepository : IFeedbackRepository + { + private FeedbackEntry? _current; + + public FakeFeedbackRepository(FeedbackEntry? current) => _current = current; + + public List Written { get; } = new(); + public bool UpdateCalled { get; private set; } + + public Task UpdateInAppFeedbackAsync(string upn, Func 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 GetInAppFeedbackAsync(string upn) => throw new NotSupportedException(); + public Task GetOffboardingFeedbackAsync(string historyRowKey) => throw new NotSupportedException(); + public Task SaveOffboardingFeedbackAsync(FeedbackEntry entry) => throw new NotSupportedException(); + public Task SaveGeneralFeedbackAsync(FeedbackEntry entry) => throw new NotSupportedException(); + public Task CountGeneralFeedbackSinceAsync(string upn, DateTime sinceUtc) => throw new NotSupportedException(); + public Task> GetAllAsync() => throw new NotSupportedException(); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs index 491911d3..0e9854a2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -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 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; } /// @@ -122,9 +128,9 @@ namespace AutopilotMonitor.Functions.Functions.Feedback } /// - /// Records one answer to the rating bubble (). - /// 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 (): a rating is + /// stored once per person, a dismissal is counted. After a rating the person's row is final: + /// further answers change nothing. /// [Function("SubmitFeedback")] public async Task Submit( @@ -140,34 +146,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback // Request body size limit (1 MB) var read = await req.ReadAsync(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 } } + /// + /// The answer decision without the HTTP shell, so tests drive every branch directly (same + /// pattern as SubmitGeneralFeedbackFunction.ProcessSubmitAsync). Returns the validation + /// error, or null once the answer is handled. + /// + internal async Task 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; + } + + /// + /// 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. + /// + 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); + } + /// /// Returns all feedback entries for the Global Admin dashboard. /// diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/SubmitGeneralFeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/SubmitGeneralFeedbackFunction.cs index e632b18e..4790d91d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/SubmitGeneralFeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/SubmitGeneralFeedbackFunction.cs @@ -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 _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); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 7fd819d5..66f9de9e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 6a5bb335..be2c2efd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -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 { @@ -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 { @@ -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); } + /// + /// Fired when a member rates the portal in the feedback bubble — at most once per person, never + /// for a dismissal. Info. Like , 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). + /// + public Task RecordFeedbackRatingReceivedAsync( + string tenantId, string submittedBy, string? domainName, int rating, int commentLength) + { + var details = new Dictionary + { + ["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 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 /// 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 /// The provider accepted the activation welcome mail. Info-tier confirmation. 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 /// 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 /// 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 /// The provider accepted the farewell mail. Info-tier confirmation. 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 /// 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 /// 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 /// Heads-up: a Pro trial ends within the next few days. Info-tier visibility signal. 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 /// 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 ────────────────────────────────────────────────── + /// How a message names a tenant: "domain (tenantId)", or the bare id without a domain. + 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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs index a02320c6..355ce675 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs @@ -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. /// - 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 { diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts index ac0d243d..08e178e0 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts @@ -111,6 +111,7 @@ export const OPS_EVENT_TYPES = [ "McpServicePrincipalFirstSeen", "OffboardingFeedbackReceived", "FeedbackReceived", + "FeedbackRatingReceived", "TenantSignup", "SessionReportSubmitted", "DiagFilesReportSubmitted", diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index de9c3f40..461ec253 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -137,6 +137,9 @@ namespace AutopilotMonitor.Shared /// The tenant's hardware whitelist settings, which also list the rejected models. public const string PortalHardwareWhitelistPath = "/settings/tenant/hardware-whitelist"; + /// The Global Admin list of every feedback kind (help menu, rating bubble, offboarding). + public const string PortalUserFeedbackPath = "/admin/reports/user-feedback"; + /// /// 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. diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs index 75b62a1a..a21dfa79 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs @@ -109,6 +109,12 @@ namespace AutopilotMonitor.Shared.DataAccess /// public const string FeedbackReceived = "FeedbackReceived"; /// + /// A member rated the portal in the feedback bubble (POST feedback) — 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). + /// + public const string FeedbackRatingReceived = "FeedbackRatingReceived"; + /// /// A new tenant signed up: its first sign-in seeded the domain (auth/me). 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, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 47979bd2..76d70a5f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -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 = { @@ -170,6 +172,9 @@ const OPS_EVENT_TYPES: Record = { // 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 = { ], }; +const OPS_EVENT_TYPE_COUNT = Object.values(OPS_EVENT_TYPES).flat().length; + const SEVERITIES = ["Info", "Warning", "Error", "Critical"]; const CATEGORY_COLORS: Record = { @@ -348,6 +355,7 @@ export function OpsAlertRulesSection({ const [autoActionMode, setAutoActionMode] = useState(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 (
{/* 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={ + + } />
{loadingConfig ? ( @@ -427,7 +450,14 @@ export function OpsAlertRulesSection({
) : (
- {Object.entries(OPS_EVENT_TYPES).map(([category, eventTypes]) => ( + {trimmedQuery !== "" && ( +

+ {shownCount === 0 + ? `No alert rules match "${trimmedQuery}".` + : `${shownCount} of ${OPS_EVENT_TYPE_COUNT} rules match.`} +

+ )} + {visibleCatalog.map(([category, eventTypes]) => (

{category} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx index 368797c3..6c1acbd4 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -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 */}
-
- - - - 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 && ( - - )} -
+
Filter: + )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/navSectionReachability.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/navSectionReachability.test.ts new file mode 100644 index 00000000..9a5594f5 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/navSectionReachability.test.ts @@ -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 = { + "/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([]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.generated.ts index a6a29fa6..5c1df7ce 100644 --- a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.generated.ts @@ -656,6 +656,7 @@ export const SHARED_MANIFEST = { "McpServicePrincipalFirstSeen", "OffboardingFeedbackReceived", "FeedbackReceived", + "FeedbackRatingReceived", "TenantSignup", "SessionReportSubmitted", "DiagFilesReportSubmitted", diff --git a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json index 4739545e..c0094a34 100644 --- a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json @@ -652,6 +652,7 @@ "McpServicePrincipalFirstSeen", "OffboardingFeedbackReceived", "FeedbackReceived", + "FeedbackRatingReceived", "TenantSignup", "SessionReportSubmitted", "DiagFilesReportSubmitted", diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index 2e7ec255..eb2844fc 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -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: , 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" },