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,