mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+14
@@ -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>
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+39
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user