From caeb7a3e5899f575c06b8c990fe52af33d5872dd Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 14 Oct 2026 17:19:22 +0200 Subject: [PATCH] feat: every alert links to its subject; ops alerts open their own event (D-360) A push carries only category, event and severity for an ops alert, so the portal link is the way to the details, and ops alerts had none: the event id was minted inside the save and never reached the dispatch. Backend: SaveOpsEventAsync sets OpsEventEntry.Id (OpsEventId, {Category}_{RowKey}); WriteAsync hands it to the dispatch, and BuildAlert adds "Open in the portal" -> /admin/ops/?eventId= on the plain and the payload path, so every ops channel (push, Telegram, Teams, Slack, Discord, e-mail, JSON) gets it. GET global/ops-events?id= is a point read (400 for a malformed id, an empty list for a deleted one). hardware_rejected links to the hardware whitelist (one path constant, also for the bell); the session watch link names the session's tenant; TenantSignup's portalPath keeps its tenant query. Only a session link makes a push session-bound: tag and Topic no longer key on any "id=" in the link. Web: /admin/ops?eventId= opens that event's detail from the point read (matched by id), and the detail links to the tenant and to the event's related page (same-origin paths only) besides the session, which now carries the tenant. CopyButton and the cursor pager footer are shared components instead of four and three copies; the duplicate baseline shrinks by five entries. Co-Authored-By: Claude Opus 5.5 --- .../OpsEventDeepLinkTests.cs | 192 ++++++++++++++++++ .../Push/PushAlertProjectorTests.cs | 59 ++++++ .../TableStorage/TableOpsEventRepository.cs | 12 +- .../Functions/Admin/GetOpsEventsFunction.cs | 16 +- .../Ingest/ReportDistressFunction.cs | 5 +- .../Notifications/NotificationAlertBuilder.cs | 5 + .../Services/OpsAlertDispatchService.cs | 21 +- .../Services/OpsEventService.cs | 5 +- .../Services/Push/PushAlertProjector.cs | 12 +- .../Services/Push/PushSessionWatchService.cs | 2 +- .../AutopilotMonitor.Shared/Constants.cs | 18 ++ .../DataAccess/IOpsEventRepository.cs | 49 +++++ .../app/admin/components/CopyButton.tsx | 32 +++ .../admin/components/CursorPagerFooter.tsx | 38 ++++ .../components/DistressReportsSection.tsx | 29 +-- .../app/admin/components/OpsEventsSection.tsx | 108 +++++----- .../components/RuleSubmissionsSection.tsx | 34 +--- .../components/SessionReportsSection.tsx | 52 +---- .../components/opsEventSessionHelpers.ts | 27 ++- .../opsEventSessionExtraction.test.ts | 32 ++- src/Web/autopilot-monitor-web/lib/api.ts | 2 + src/Web/autopilot-monitor-web/lib/routes.ts | 5 + tools/quality/jscpd-baseline.json | 5 - 23 files changed, 578 insertions(+), 182 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/OpsEventDeepLinkTests.cs create mode 100644 src/Web/autopilot-monitor-web/app/admin/components/CopyButton.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/components/CursorPagerFooter.tsx diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventDeepLinkTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventDeepLinkTests.cs new file mode 100644 index 00000000..c12cc3ab --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventDeepLinkTests.cs @@ -0,0 +1,192 @@ +using System.Net; +using System.Security.Claims; +using System.Text.Json; +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Functions.Functions.Admin; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Every ops alert links to exactly the event that raised it: the save assigns the id, the +/// dispatch carries it into an "Open in the portal" action on every path, and the ops-events +/// endpoint resolves it with one point read. Before this an ops push had no link at all. +/// +public class OpsEventDeepLinkTests +{ + private const string RowKey = "2516103215872799164"; + private static readonly string SecurityId = OpsEventCategory.Security + "_" + RowKey; + + // ── The id shape ────────────────────────────────────────────────────── + + [Fact] + public void OpsEventId_round_trips_every_category() + { + foreach (var category in OpsEventCategory.All) + { + Assert.True(OpsEventId.TryParse(OpsEventId.Format(category, RowKey), out var c, out var rk)); + Assert.Equal(category, c); + Assert.Equal(RowKey, rk); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Security")] // no RowKey + [InlineData("_2516103215872799164")] // no category + [InlineData("Unknown_2516103215872799164")] // not a category + [InlineData("security_2516103215872799164")] // PartitionKeys are case-sensitive + [InlineData("Security_251610321587279916")] // 18 digits + [InlineData("Security_25161032158727991640")] // 20 digits + [InlineData("Security_25161032158727991x4")] // not a digit + [InlineData("Security_' or 'a' eq 'a")] // never reaches a query + public void OpsEventId_rejects_anything_that_is_not_an_id(string? id) + => Assert.False(OpsEventId.TryParse(id, out _, out _)); + + [Fact] + public void Read_rows_carry_the_same_id_the_link_uses() + { + var entry = TableOpsEventRepository.MapToEntry(new TableEntity(OpsEventCategory.Security, RowKey)); + + Assert.Equal(SecurityId, entry.Id); + } + + [Fact] + public void The_portal_link_carries_the_id_only() + => Assert.Equal(Constants.PortalBaseUrl + "/admin/ops/?eventId=" + SecurityId, Constants.PortalOpsEventUrl(SecurityId)); + + // ── GET global/ops-events?id= ───────────────────────────────────────── + + [Fact] + public async Task Id_query_is_a_point_read_that_ignores_the_list_filters() + { + var repo = new Mock(); + repo.Setup(r => r.GetOpsEventAsync(SecurityId)) + .ReturnsAsync(new OpsEventEntry { Id = SecurityId, Category = OpsEventCategory.Security, EventType = "DeviceBlocked" }); + + var (status, body) = await RunAsync(repo, $"?id={SecurityId}&category=Tenant&pageSize=5"); + + Assert.Equal(HttpStatusCode.OK, status); + Assert.Equal(1, body.GetProperty("count").GetInt32()); + Assert.Equal(SecurityId, body.GetProperty("events")[0].GetProperty("id").GetString()); + repo.Verify(r => r.GetOpsEventsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Id_query_for_a_gone_event_is_an_empty_list_not_an_error() + { + var repo = new Mock(); + + var (status, body) = await RunAsync(repo, $"?id={SecurityId}"); + + Assert.Equal(HttpStatusCode.OK, status); + Assert.Equal(0, body.GetProperty("count").GetInt32()); + } + + [Fact] + public async Task Malformed_id_is_a_400_and_never_reaches_storage() + { + var repo = new Mock(); + + var (status, _) = await RunAsync(repo, "?id=Security_x"); + + Assert.Equal(HttpStatusCode.BadRequest, status); + repo.Verify(r => r.GetOpsEventAsync(It.IsAny()), Times.Never); + } + + private static async Task<(HttpStatusCode Status, JsonElement Body)> RunAsync(Mock repo, string query) + { + const string callerTenant = "00000000-0000-0000-0000-000000000001"; + var (req, context) = EndpointHarness.Request(callerTenant, queryString: query); + context.Items["ClaimsPrincipal"] = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", callerTenant) }, "test")); + var response = await new GetOpsEventsFunction(NullLogger.Instance, repo.Object).Run(req); + response.Body.Position = 0; + using var doc = JsonDocument.Parse(response.Body); + return (response.StatusCode, doc.RootElement.Clone()); + } + + // ── The alert carries the link ──────────────────────────────────────── + + [Fact] + public async Task Both_the_plain_and_the_payload_send_link_to_the_event() + { + var h = new OpsNotificationChannelsTests.DispatchHarness( + new[] + { + new OpsAlertRule { EventType = "DeviceBlocked", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List { "push" } }, + new OpsAlertRule { EventType = "DeviceBlocked", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List { "hook" }, IncludePayload = true }, + }, + new[] + { + new NotificationChannel { Id = "push", Name = "push", ProviderType = 20, Url = "https://push.example.invalid/hook", Enabled = true }, + new NotificationChannel { Id = "hook", Name = "hook", ProviderType = 20, Url = "https://hook.example.invalid/hook", Enabled = true }, + }); + + await h.Sut.DispatchAsync(OpsEventCategory.Security, "DeviceBlocked", OpsEventSeverity.Warning, + "Device blocked", "11111111-1111-1111-1111-111111111111", """{"serialNumber":"SYNTH-0001"}""", SecurityId); + + Assert.Equal(2, h.Sends.Count); + Assert.All(h.Sends, s => + { + var action = Assert.Single(s.Alert.Actions); + Assert.Equal("openUrl", action.Type); + Assert.Equal(Constants.PortalOpsEventUrl(SecurityId), action.Url); + }); + } + + [Fact] + public void An_alert_without_an_id_has_no_action() + => Assert.Empty(OpsAlertDispatchService.BuildAlert(OpsEventCategory.Security, "DeviceBlocked", + OpsEventSeverity.Warning, "m", null, null, opsEventId: null).Actions); + + [Fact] + public async Task The_service_dispatches_the_id_the_save_assigned() + { + var repo = new Mock(); + repo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(e => e.Id = OpsEventId.Format(e.Category, RowKey)) + .Returns(Task.CompletedTask); + var h = new OpsNotificationChannelsTests.DispatchHarness( + new[] { new OpsAlertRule { EventType = OpsEventTypes.TenantSignup, MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List { "push" } } }, + new[] { new NotificationChannel { Id = "push", Name = "push", ProviderType = 20, Url = "https://push.example.invalid/hook", Enabled = true } }); + var service = new OpsEventService(repo.Object, NullLogger.Instance, h.Sut); + + await service.RecordTenantSignupAsync("11111111-1111-1111-1111-111111111111", "admin@contoso.invalid"); + + // The dispatch is fire-and-forget behind the save; the mocks complete synchronously, + // the short wait only guards against a scheduler hop. + for (var i = 0; i < 50 && h.Sends.Count == 0; i++) + await Task.Delay(10); + var send = Assert.Single(h.Sends); + Assert.Equal(Constants.PortalOpsEventUrl(OpsEventCategory.Tenant + "_" + RowKey), Assert.Single(send.Alert.Actions).Url); + } + + [Fact] + public async Task Tenant_signup_points_its_related_page_at_the_tenant() + { + OpsEventEntry? saved = null; + var repo = new Mock(); + repo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(e => saved = e) + .Returns(Task.CompletedTask); + var h = new OpsNotificationChannelsTests.DispatchHarness(Array.Empty(), Array.Empty()); + var service = new OpsEventService(repo.Object, NullLogger.Instance, h.Sut); + + await service.RecordTenantSignupAsync("11111111-1111-1111-1111-111111111111", "admin@contoso.invalid"); + + using var details = JsonDocument.Parse(saved!.Details!); + // The bare /admin/tenants redirects and drops the query; the section path keeps it. + Assert.Equal("/admin/tenants/management?tenantId=11111111-1111-1111-1111-111111111111", + details.RootElement.GetProperty("portalPath").GetString()); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs index 90a6ec2a..ec4a5209 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Functions.Services.Push; using AutopilotMonitor.Shared; @@ -193,6 +194,64 @@ public class PushAlertProjectorTests Assert.Null(PushAlertProjector.BuildTopic(Constants.PortalBaseUrl, Encoding.UTF8.GetBytes("key"))); } + [Fact] + public void Ops_push_links_to_its_event_and_stays_its_own_notification() + { + const string eventId = "Security_2516103215872799164"; + var alert = OpsAlertDispatchService.BuildAlert("Security", "ExcessiveSessionEventsAutoActioned", "Critical", + "Auto-block device " + Sentinel, "11111111-1111-1111-1111-111111111111", detailsJson: null, eventId); + + var payload = PushAlertProjector.Project(alert, NotificationScope.Platform, "e6", Now); + var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(payload)); + + Assert.Equal(Constants.PortalOpsEventUrl(eventId), payload.PortalUrl); + // Two ops alerts must never replace each other on the lock screen or in the push service queue. + Assert.Equal("e6", payload.Tag); + Assert.Null(PushAlertProjector.BuildTopic(payload.PortalUrl, Encoding.UTF8.GetBytes("key"))); + Assert.DoesNotContain(Sentinel, json); + } + + [Fact] + public void Only_a_session_link_binds_a_push_to_a_session() + { + Alert(Constants.PortalSessionUrl("sess-1"), out var a, "a"); + Alert(Constants.PortalSessionUrl("sess-1"), out var b, "b"); + Alert(Constants.PortalBaseUrl + "/admin/backups/detail?id=backup-1", out var backup, "c"); + + Assert.Equal(a.Tag, b.Tag); + Assert.StartsWith("s-", a.Tag); + Assert.Equal("c", backup.Tag); + Assert.Null(PushAlertProjector.BuildTopic(backup.PortalUrl, Encoding.UTF8.GetBytes("key"))); + + static void Alert(string url, out PushPayload payload, string entryId) + { + var alert = EnrollmentFailed(); + alert.Actions.Clear(); + alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open", Url = url }); + payload = PushAlertProjector.Project(alert, Tenant, entryId, Now); + } + } + + [Fact] + public void Session_watch_link_names_the_session_tenant_for_a_cross_tenant_watcher() + { + var url = Constants.PortalSessionUrl("sess-1", "22222222-2222-2222-2222-222222222222"); + + Assert.Equal(Constants.PortalBaseUrl + "/sessions?id=sess-1&tenantId=22222222-2222-2222-2222-222222222222", url); + Assert.NotNull(PushAlertProjector.BuildTopic(url, Encoding.UTF8.GetBytes("key"))); + } + + [Fact] + public void Hardware_rejected_push_links_to_the_whitelist() + { + var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert("Contoso", "Model 1", "ZZ998877"); + + var payload = PushAlertProjector.Project(alert, Tenant, "e7", Now); + + Assert.Equal(Constants.PortalBaseUrl + Constants.PortalHardwareWhitelistPath, payload.PortalUrl); + Assert.Equal("e7", payload.Tag); + } + [Fact] public void System_messages_never_share_a_tag_so_none_replaces_an_earlier_notification() { diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs index 1cb9a84b..f43b0b91 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs @@ -50,6 +50,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage }; await _table.UpsertEntityAsync(entity); + entry.Id = OpsEventId.Format(pk, rk); + } + + public async Task GetOpsEventAsync(string id) + { + if (!OpsEventId.TryParse(id, out var category, out var rowKey)) + return null; + + var response = await _table.GetEntityIfExistsAsync(category, rowKey); + return response.HasValue ? MapToEntry(response.Value!) : null; } public async Task> GetOpsEventsAsync( @@ -239,7 +249,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { return new OpsEventEntry { - Id = $"{entity.PartitionKey}_{entity.RowKey}", + Id = OpsEventId.Format(entity.PartitionKey, entity.RowKey), Category = entity.PartitionKey, EventType = entity.GetString("EventType") ?? string.Empty, Severity = entity.GetString("Severity") ?? OpsEventSeverity.Info, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs index 9c5adaea..2d7f650d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs @@ -14,7 +14,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin /// /// Cross-tenant READ endpoint for PLATFORM-OPERATIONAL events (Consent/Maintenance/Security/Tenant/Agent/ /// SLA). Supports optional category + eventType/severity/minSeverity + dateFrom/dateTo filters (all - /// server-side except the tenant drill) and opt-in pagination. Authentication + + /// server-side except the tenant drill) and opt-in pagination, or one event by ?id=. Authentication + /// GlobalReadOrAdmin + TenantScoping.None enforced by PolicyEnforcementMiddleware — a Global Admin and the /// read-only Global Reader reach it, but a delegated ("MSP") admin does NOT: ops-events is the platform /// operator's view, not customer telemetry, so it stays GA/Reader-only (None ⇒ no scoped-route rescue for @@ -45,6 +45,20 @@ namespace AutopilotMonitor.Functions.Functions.Admin { var callerTenantId = TenantHelper.GetTenantId(req); var query = req.Query; + + // ?id= is the deep link of one event (the ops-alert "Open in the portal" action): + // a point read that ignores every other parameter. + var id = query["id"]; + if (!string.IsNullOrEmpty(id)) + { + if (!OpsEventId.TryParse(id, out _, out _)) + return await req.BadRequestAsync("id must be an ops event id ({Category}_{19-digit RowKey})"); + + var single = await _repository.GetOpsEventAsync(id); + var found = single == null ? new List() : new List { single }; + return await req.OkAsync(new OpsEventListResponse { Success = true, Count = found.Count, Events = found }); + } + var category = query["category"]; var filterTenantId = query["tenantId"]; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs index c4418353..9c9bb363 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; @@ -267,7 +268,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest type: "hardware_rejection", title: "New rejected hardware model", message: $"{manufacturer} {model} was rejected by the hardware whitelist.", - href: "/settings/tenant/hardware-whitelist"); + href: Constants.PortalHardwareWhitelistPath); } } catch (Exception bellEx) @@ -297,7 +298,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest type: "tpm_pss_unsupported", title: "Device with incompatible TPM detected", message: $"Device {serialNumber}{(deviceLabel.Length > 0 ? $" ({deviceLabel})" : "")} reported a TPM that cannot perform RSA-PSS signing — the agent on this device cannot authenticate. A TPM firmware update or device replacement is required.", - href: "/settings/tenant/hardware-whitelist"); + href: Constants.PortalHardwareWhitelistPath); } } catch (Exception bellEx) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs index 8f0c9100..777d9a00 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs @@ -193,6 +193,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications new() { Name = "Serial", Value = serialNumber ?? "\u2013" }, new() { Name = "Data Quality", Value = "Unverified (pre-auth distress signal)" }, }, + // No session exists for a rejected device: the whitelist page lists the rejected models. + Actions = new List + { + new() { Type = "openUrl", Title = "Open hardware whitelist", Url = Constants.PortalBaseUrl + Constants.PortalHardwareWhitelistPath }, + }, }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs index 74a73e22..5fd9c9d3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; @@ -33,10 +34,11 @@ namespace AutopilotMonitor.Functions.Services /// /// Evaluates alert rules for the given ops event and dispatches to the channels those - /// rules target. Safe to call fire-and-forget — never throws. + /// rules target. Safe to call fire-and-forget — never throws. + /// is the stored row's ; every alert links to it. /// public async Task DispatchAsync(string category, string eventType, string severity, - string message, string? tenantId, string? detailsJson = null) + string message, string? tenantId, string? detailsJson = null, string? opsEventId = null) { try { @@ -63,14 +65,14 @@ namespace AutopilotMonitor.Functions.Services if (plain.Count > 0) { await _channelDispatcher.SendToChannelsAsync(plain, - BuildAlert(category, eventType, severity, message, tenantId, detailsJson: null), + BuildAlert(category, eventType, severity, message, tenantId, detailsJson: null, opsEventId), NotificationScope.Platform); } if (withPayload.Count > 0) { await _channelDispatcher.SendToChannelsAsync(withPayload, - BuildAlert(category, eventType, severity, message, tenantId, detailsJson), + BuildAlert(category, eventType, severity, message, tenantId, detailsJson, opsEventId), NotificationScope.Platform); } @@ -145,8 +147,8 @@ namespace AutopilotMonitor.Functions.Services internal const int MaxDetailFacts = 12; internal const int MaxDetailValueLength = 256; - private static NotificationAlert BuildAlert(string category, string eventType, - string severity, string message, string? tenantId, string? detailsJson) + internal static NotificationAlert BuildAlert(string category, string eventType, + string severity, string message, string? tenantId, string? detailsJson, string? opsEventId) { var notifSeverity = severity switch { @@ -176,6 +178,12 @@ namespace AutopilotMonitor.Functions.Services facts.AddRange(FlattenDetails(detailsJson)); + // The link to the event is not payload: the event's page holds the details a channel + // may not carry (a push carries no message at all), so every alert gets it. + var actions = new List(); + if (!string.IsNullOrEmpty(opsEventId)) + actions.Add(new NotificationAction { Type = "openUrl", Title = "Open in the portal", Url = Constants.PortalOpsEventUrl(opsEventId!) }); + return new NotificationAlert { // Machine-readable routing key for generic consumers — the same value the @@ -187,6 +195,7 @@ namespace AutopilotMonitor.Functions.Services Severity = notifSeverity, ThemeColor = themeColor, Facts = facts, + Actions = actions, DataJson = detailsJson, }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index ccfd5295..6a5bb335 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -1295,7 +1295,7 @@ namespace AutopilotMonitor.Functions.Services public virtual Task RecordTenantSignupAsync(string tenantId, string upn) => WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantSignup, OpsEventSeverity.Info, "New tenant signed up", tenantId, upn, - new { portalPath = "/admin/tenants" }); + new { portalPath = $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}" }); public virtual Task RecordSessionReportSubmittedAsync(string tenantId, string submittedBy, string sessionId, string reportId) => WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.SessionReportSubmitted, OpsEventSeverity.Info, @@ -1337,7 +1337,8 @@ namespace AutopilotMonitor.Functions.Services // DispatchAsync has its own top-level try/catch so unobserved exceptions are safe. // The serialized details ride along: an outbound channel that only knows category, // event and tenant GUID forces the reader back into the portal for every alert. - _ = _alertDispatch.DispatchAsync(category, eventType, severity, message, tenantId, entry.Details); + // The id the save assigned becomes the alert's link to exactly this event. + _ = _alertDispatch.DispatchAsync(category, eventType, severity, message, tenantId, entry.Details, entry.Id); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushAlertProjector.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushAlertProjector.cs index b683e117..9396e719 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushAlertProjector.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushAlertProjector.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -272,13 +273,20 @@ public static class PushAlertProjector return null; } + /// + /// Only a session link binds a push to a session. Any other deep link (an ops event, a + /// whitelist page) names no session, and its pushes must never replace each other. + /// + private static bool IsSessionUrl([NotNullWhen(true)] string? portalUrl) => + portalUrl != null && portalUrl.StartsWith(Constants.PortalBaseUrl + "/sessions?id=", StringComparison.OrdinalIgnoreCase); + /// /// Session-bound alerts collapse per session where the browser honours tags (best effort, /// K21); everything else is its own notification. /// private static string BuildTag(string? portalUrl, string entryId) { - if (portalUrl == null || !portalUrl.Contains("id=", StringComparison.Ordinal)) + if (!IsSessionUrl(portalUrl)) return entryId; var digest = SHA256.HashData(Encoding.UTF8.GetBytes(portalUrl)); return "s-" + Convert.ToHexString(digest, 0, 6).ToLowerInvariant(); @@ -291,7 +299,7 @@ public static class PushAlertProjector /// public static string? BuildTopic(string? portalUrl, byte[]? hmacKey) { - if (portalUrl == null || hmacKey == null || hmacKey.Length == 0 || !portalUrl.Contains("id=", StringComparison.Ordinal)) + if (!IsSessionUrl(portalUrl) || hmacKey == null || hmacKey.Length == 0) return null; var mac = HMACSHA256.HashData(hmacKey, Encoding.UTF8.GetBytes(portalUrl)); return System.Buffers.Text.Base64Url.EncodeToString(mac.AsSpan(0, 24)); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs index 23d07ea7..6bb2c957 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs @@ -173,7 +173,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier foreach (var scope in ScopesOf(watch, tenantId)) { var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now, - Constants.PortalSessionUrl(sessionId)); + Constants.PortalSessionUrl(sessionId, tenantId)); await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false); } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 435e4558..de9c3f40 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -119,6 +119,24 @@ namespace AutopilotMonitor.Shared public static string PortalSessionUrl(string sessionId) => $"{PortalBaseUrl}/sessions?id={System.Uri.EscapeDataString(sessionId ?? string.Empty)}"; + /// + /// The session deep link for a reader who may sit outside the session's tenant (a Global + /// Admin's session watch): the portal opens the session's tenant directly instead of + /// trying the reader's own first. + /// + public static string PortalSessionUrl(string sessionId, string tenantId) => + $"{PortalSessionUrl(sessionId)}&tenantId={System.Uri.EscapeDataString(tenantId ?? string.Empty)}"; + + /// + /// Opens one ops event's detail in the Ops page (Global Admin only). Carries the opaque + /// event id only: the details stay behind the portal sign-in. + /// + public static string PortalOpsEventUrl(string opsEventId) => + $"{PortalBaseUrl}/admin/ops/?eventId={System.Uri.EscapeDataString(opsEventId ?? string.Empty)}"; + + /// The tenant's hardware whitelist settings, which also list the rejected models. + public const string PortalHardwareWhitelistPath = "/settings/tenant/hardware-whitelist"; + /// /// 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/IOpsEventRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs index 060dd198..72661834 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs @@ -11,8 +11,18 @@ namespace AutopilotMonitor.Shared.DataAccess /// public interface IOpsEventRepository { + /// + /// Writes the event and sets to the stored row's + /// , so the caller can link to exactly this row. + /// Task SaveOpsEventAsync(OpsEventEntry entry); + /// + /// Point read of one event by its ; null when the id is malformed + /// or the row no longer exists (retention). + /// + Task GetOpsEventAsync(string id); + /// /// Returns all matching ops events in the given UTC window. /// is optional — when provided, scopes to a single PartitionKey for an @@ -61,6 +71,45 @@ namespace AutopilotMonitor.Shared.DataAccess public static readonly string[] All = { Consent, Maintenance, Security, Tenant, Agent, Sla, Platform }; } + /// + /// The one id shape of an ops event: {Category}_{RowKey}, the RowKey being the 19-digit + /// inverted-tick key. The portal deep link (/admin/ops?eventId=) and the + /// ?id= lookup carry it, so it must stay parseable back to PartitionKey + RowKey. + /// + public static class OpsEventId + { + private const int RowKeyLength = 19; + + public static string Format(string category, string rowKey) => $"{category}_{rowKey}"; + + /// True for a known category followed by a 19-digit RowKey; nothing else is an id. + public static bool TryParse(string? id, out string category, out string rowKey) + { + category = string.Empty; + rowKey = string.Empty; + if (string.IsNullOrEmpty(id)) + return false; + + var separator = id!.LastIndexOf('_'); + if (separator <= 0 || id.Length - separator - 1 != RowKeyLength) + return false; + + var candidate = id.Substring(0, separator); + if (Array.IndexOf(OpsEventCategory.All, candidate) < 0) + return false; + + for (var i = separator + 1; i < id.Length; i++) + { + if (id[i] < '0' || id[i] > '9') + return false; + } + + category = candidate; + rowKey = id.Substring(separator + 1); + return true; + } + } + /// /// Severity levels for operational events. /// diff --git a/src/Web/autopilot-monitor-web/app/admin/components/CopyButton.tsx b/src/Web/autopilot-monitor-web/app/admin/components/CopyButton.tsx new file mode 100644 index 00000000..d5082aaa --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/components/CopyButton.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState } from "react"; + +/** Small inline copy-to-clipboard icon next to an id in the admin detail views. */ +export function CopyButton({ value, title = "Copy to clipboard" }: { value: string; title?: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + return ( + + ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/CursorPagerFooter.tsx b/src/Web/autopilot-monitor-web/app/admin/components/CursorPagerFooter.tsx new file mode 100644 index 00000000..bb71e4a1 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/components/CursorPagerFooter.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { CursorPager } from "@/hooks/useCursorPager"; + +type PagerControls = Pick, "pageNumber" | "hasNext" | "hasPrev" | "next" | "prev">; + +const BUTTON = + "px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"; + +/** Footer of the admin tables paged by the backend (Pattern B1): the row count, the page, Previous and Next. */ +export function CursorPagerFooter({ + pager, + count, + loading, + filtered = false, +}: { + pager: PagerControls; + count: number; + loading: boolean; + /** The count is a client-side filter of the page, not the page itself. */ + filtered?: boolean; +}) { + return ( +
+ + {count} on page {pager.pageNumber}{filtered ? " (filtered)" : ""}{pager.hasNext ? "" : " (last)"} + +
+ + +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx index 80500c6a..7495c068 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx @@ -6,6 +6,7 @@ import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; import type { DistressReportEntry, DistressReportListResponse } from "@/lib/generated/wire-types.generated"; import { ModalPortal } from "@/components/ModalPortal"; +import { CopyButton } from "./CopyButton"; /** One stored distress report — the wire shape (optional where the agent sent nothing). */ type DistressReport = DistressReportEntry; @@ -69,34 +70,6 @@ function SortableTh({ ); } -function CopyButton({ value }: { value: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(value).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; - return ( - - ); -} - export function DistressReportsSection({ getAccessToken, setError, 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 8d376a51..368797c3 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -1,17 +1,20 @@ "use client"; -import { sessionUrl, deviceBlockUrl } from "@/lib/routes"; -import { useEffect, useState } from "react"; +import { sessionUrl, deviceBlockUrl, tenantManagementUrl, trustedRoute } from "@/lib/routes"; +import { Suspense, useEffect, useRef, useState } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; import TruncatedLabel from "@/components/TruncatedLabel"; import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient"; import type { CursorPage } from "@/lib/cursorPager"; import { useCursorPager } from "@/hooks/useCursorPager"; -import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers"; +import { extractSessionId, extractPortalPath, buildAutoReason } from "./opsEventSessionHelpers"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import type { OpsEventEntry, OpsEventListResponse } from "@/lib/generated/wire-types.generated"; import { ModalPortal } from "@/components/ModalPortal"; +import { CursorPagerFooter } from "./CursorPagerFooter"; +import { CopyButton } from "./CopyButton"; /** One ops event row (wire shape). */ type OpsEvent = OpsEventEntry; @@ -85,35 +88,16 @@ function CategoryBadge({ category }: { category: string }) { ); } -function CopyButton({ value }: { value: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(value).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; +export function OpsEventsSection(props: OpsEventsSectionProps) { + // useSearchParams() needs a Suspense boundary for the static prerender. return ( - + + + ); } -export function OpsEventsSection({ +function OpsEventsSectionInner({ getAccessToken, setError, opsEventRetentionDays, @@ -146,6 +130,28 @@ export function OpsEventsSection({ return null; } }; + // Deep link from an ops alert ("Open in the portal"): ?eventId=… opens that event's detail + // from a point read — no dependency on the row being in the current window or page. + const searchParams = useSearchParams(); + const eventIdParam = searchParams?.get("eventId") ?? null; + const deepLinkConsumed = useRef(false); + useEffect(() => { + if (deepLinkConsumed.current || !eventIdParam) return; + deepLinkConsumed.current = true; + const run = async () => { + try { + const data = await fetchJson(api.opsEvents.get(eventIdParam), getAccessToken); + // Match the id, not the first row: a backend without the ?id= lookup answers with the list. + const event = data.events?.find((e) => e.id === eventIdParam); + if (event) setSelectedEvent(event); + else setError(`This ops event no longer exists — ops events are kept for ${opsEventRetentionDays} days.`); + } catch (err) { + setError(apiErrorText(err, "Failed to load the ops event")); + } + }; + void run(); + }, [eventIdParam, getAccessToken, setError, opsEventRetentionDays]); + const pager = useCursorPager(fetchEvents); const { reset } = pager; const events = pager.items; @@ -407,27 +413,7 @@ export function OpsEventsSection({ {/* Pagination (Pattern B1 — backend-driven) */} -
- - {filteredEvents.length} on page {pager.pageNumber}{searchQuery && ` (filtered)`}{pager.hasNext ? "" : " (last)"} - -
- - -
-
+ )} @@ -501,6 +487,28 @@ export function OpsEventsSection({ )} + {(() => { + const tenantUrl = selectedEvent.tenantId ? tenantManagementUrl(selectedEvent.tenantId) : null; + const relatedPath = extractPortalPath(selectedEvent.details ?? null); + // TenantSignup's related page IS the tenant — one button, not two. + const relatedUrl = relatedPath && relatedPath !== tenantUrl ? trustedRoute(relatedPath) : null; + if (!tenantUrl && !relatedUrl) return null; + return ( +
+ {tenantUrl && ( + + View tenant + + )} + {relatedUrl && ( + + Open related page + + )} +
+ ); + })()} + {(() => { const sessionId = extractSessionId(selectedEvent.details ?? null); if (!sessionId) return null; @@ -512,7 +520,7 @@ export function OpsEventsSection({

void; } -function CopyButton({ value, title = "Copy to clipboard" }: { value: string; title?: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(value).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; - return ( - - ); -} - function StatusBadge({ status }: { status: string }) { const b = submissionStatusBadge(status); return {b.label}; @@ -245,13 +223,7 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio ))} -
- {items.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"} -
- - -
-
+
)} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx index f15b82d7..cc10cbbd 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx @@ -14,6 +14,8 @@ import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import type { SessionReportDownloadUrlResponse, SessionReportListResponse, SessionReportMetadata, UpdateSessionReportNoteRequest } from "@/lib/generated/wire-types.generated"; import { ModalPortal } from "@/components/ModalPortal"; +import { CursorPagerFooter } from "./CursorPagerFooter"; +import { CopyButton } from "./CopyButton"; const PAGE_SIZE = 20; @@ -38,34 +40,6 @@ interface SessionReportsSectionProps { setError: (error: string | null) => void; } -function CopyButton({ value }: { value: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(value).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; - return ( - - ); -} - function AdminNoteEditor({ report, canMutate, @@ -424,27 +398,7 @@ function SessionReportsSectionInner({ {/* Pagination (Pattern B1 — backend-driven) */} -
- - {reports.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"} - -
- - -
-
+ )} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/opsEventSessionHelpers.ts b/src/Web/autopilot-monitor-web/app/admin/components/opsEventSessionHelpers.ts index ecc39e85..3f10d78c 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/opsEventSessionHelpers.ts +++ b/src/Web/autopilot-monitor-web/app/admin/components/opsEventSessionHelpers.ts @@ -1,6 +1,7 @@ -// Helpers for the Ops Events detail modal's "Block this device" / "Kill this -// device" deep-link buttons. Lives in a `.ts` file so vitest (which currently -// only loads `.test.ts` and doesn't transform JSX) can import and test them. +// Helpers for the Ops Events detail modal's deep-link buttons (the event's +// session, its device block, its related page). Lives in a `.ts` file so vitest +// (which currently only loads `.test.ts` and doesn't transform JSX) can import +// and test them. const SESSION_ID_KEYS = ["sessionId", "SessionId", "sessionID", "session_id"]; @@ -19,6 +20,26 @@ export function extractSessionId(detailsJson: string | null): string | null { } } +/** + * The page an ops event names as its subject (`portalPath` in the details, e.g. the session + * report or the tenant). Only a same-origin path qualifies: anything a browser could read as + * another origin (`//host`, `/\host`, a scheme) is dropped, since details are free JSON. + */ +export function extractPortalPath(detailsJson: string | null): string | null { + if (!detailsJson) return null; + try { + const parsed = JSON.parse(detailsJson); + if (!parsed || typeof parsed !== "object") return null; + const value = (parsed as Record).portalPath; + if (typeof value !== "string") return null; + const path = value.trim(); + if (!path.startsWith("/") || path.startsWith("//") || /[\\\u0000-\u001f]/.test(path)) return null; + return path; + } catch { + return null; + } +} + export function buildAutoReason(eventType: string, sessionId: string): string { // Always cap to 8 chars — for GUIDs this is exactly the first hex group, for // anything else it stays readable in audit-log columns. diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/opsEventSessionExtraction.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/opsEventSessionExtraction.test.ts index f03b224e..bbe7c665 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/opsEventSessionExtraction.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/opsEventSessionExtraction.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { extractSessionId, buildAutoReason } from "../../app/admin/components/opsEventSessionHelpers"; +import { extractSessionId, extractPortalPath, buildAutoReason } from "../../app/admin/components/opsEventSessionHelpers"; +import { tenantManagementUrl } from "../routes"; // Locks in the contract that connects the Ops Events detail modal's "Block this // device" / "Kill this device" deep-link buttons to the Device Block form. @@ -82,3 +83,32 @@ describe("buildAutoReason", () => { expect(buildAutoReason("ExcessiveSessionEvents", "abc")).toBe("Excessive session events on session abc"); }); }); + +// The modal's "Open related page" follows an ops event's details.portalPath. Details are free +// JSON, so only a same-origin path may become a link. +describe("extractPortalPath", () => { + it("returns an in-app path with its query", () => { + expect(extractPortalPath(JSON.stringify({ portalPath: "/admin/reports/session-reports?reportId=r1" }))) + .toBe("/admin/reports/session-reports?reportId=r1"); + }); + + it("returns null without a usable portalPath", () => { + expect(extractPortalPath(null)).toBeNull(); + expect(extractPortalPath("not json")).toBeNull(); + expect(extractPortalPath(JSON.stringify({ sessionId: "s" }))).toBeNull(); + expect(extractPortalPath(JSON.stringify({ portalPath: 42 }))).toBeNull(); + }); + + it("refuses anything a browser could resolve to another origin", () => { + for (const portalPath of ["//evil.example", "/\\evil.example", "https://evil.example/x", "javascript:alert(1)", "admin/ops", "/admin\nops"]) { + expect(extractPortalPath(JSON.stringify({ portalPath }))).toBeNull(); + } + }); + + // TenantSignup's portalPath is built by the backend in exactly this shape; the modal shows + // one "View tenant" button instead of two only while both sides agree. + it("matches the tenant link the backend writes for a signup", () => { + const tenantId = "11111111-1111-1111-1111-111111111111"; + expect(tenantManagementUrl(tenantId)).toBe(`/admin/tenants/management?tenantId=${tenantId}`); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 124064b1..c5a09231 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -643,6 +643,8 @@ export const api = { pageSize: opts?.pageSize?.toString(), continuation: opts?.continuation, })}`, + /** One event by its id (`{Category}_{RowKey}`); an empty list when it is gone. */ + get: (id: string) => `${API_BASE_URL}/api/global/ops-events${qs({ id })}`, }, // ── Notifications ───────────────────────────────────────────────────────── diff --git a/src/Web/autopilot-monitor-web/lib/routes.ts b/src/Web/autopilot-monitor-web/lib/routes.ts index 8db72102..73cc2e60 100644 --- a/src/Web/autopilot-monitor-web/lib/routes.ts +++ b/src/Web/autopilot-monitor-web/lib/routes.ts @@ -91,6 +91,11 @@ export function deviceBlockUrl( return withQuery("/admin/security/device-block", { sessionId, reason, action }); } +/** Tenant Management narrowed to one tenant (its search box takes the id). */ +export function tenantManagementUrl(tenantId: string): Route { + return withQuery("/admin/tenants/management", { tenantId }); +} + export function customsArchiveUrl(tenantId: string, historyRowKey: string): Route { return withQuery("/admin/customs-archive/detail", { tenantId, diff --git a/tools/quality/jscpd-baseline.json b/tools/quality/jscpd-baseline.json index c050727c..456c6ae3 100644 --- a/tools/quality/jscpd-baseline.json +++ b/tools/quality/jscpd-baseline.json @@ -3,7 +3,6 @@ "fingerprints": { "016e952b3d382f92": 1, "04f18ccf5471e650": 1, - "070f759a953f319a": 1, "09722ff0399d0079": 1, "0d52e627a698ed73": 1, "1102405933d6d36d": 1, @@ -39,12 +38,10 @@ "654a9df3c383502e": 1, "6792a21ecd44b52c": 1, "6a3941fb9f2fdf26": 1, - "71e22c4690beef10": 1, "7247997f500a7474": 1, "757cad917cd84ca1": 1, "7eadf5a3615bfd95": 1, "8084f14fe903eabb": 1, - "82c45324e90ea66a": 1, "853f4f98ba80f168": 1, "8be66cde251a0e1c": 1, "8d336378a41f94e6": 1, @@ -63,9 +60,7 @@ "ab6861a9ea29854d": 1, "af270234f673d876": 1, "b26235044348e7ba": 1, - "b4fc75e3c24c9138": 1, "b62f5f7b96567483": 1, - "b90f4d00cd591f40": 1, "bf3345bef96687f1": 1, "c20b0ee6fe8ea9bb": 1, "c69b00e7d0d6e3dd": 1,