mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
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=<id> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IOpsEventRepository>();
|
||||
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<string?>(), It.IsAny<DateTime?>(), It.IsAny<DateTime?>(),
|
||||
It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<OpsEventQueryFilters?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Id_query_for_a_gone_event_is_an_empty_list_not_an_error()
|
||||
{
|
||||
var repo = new Mock<IOpsEventRepository>();
|
||||
|
||||
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<IOpsEventRepository>();
|
||||
|
||||
var (status, _) = await RunAsync(repo, "?id=Security_x");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, status);
|
||||
repo.Verify(r => r.GetOpsEventAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
private static async Task<(HttpStatusCode Status, JsonElement Body)> RunAsync(Mock<IOpsEventRepository> 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<GetOpsEventsFunction>.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<string> { "push" } },
|
||||
new OpsAlertRule { EventType = "DeviceBlocked", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List<string> { "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<IOpsEventRepository>();
|
||||
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(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<string> { "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<OpsEventService>.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<IOpsEventRepository>();
|
||||
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => saved = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
var h = new OpsNotificationChannelsTests.DispatchHarness(Array.Empty<OpsAlertRule>(), Array.Empty<NotificationChannel>());
|
||||
var service = new OpsEventService(repo.Object, NullLogger<OpsEventService>.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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+11
-1
@@ -50,6 +50,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
};
|
||||
|
||||
await _table.UpsertEntityAsync(entity);
|
||||
entry.Id = OpsEventId.Format(pk, rk);
|
||||
}
|
||||
|
||||
public async Task<OpsEventEntry?> GetOpsEventAsync(string id)
|
||||
{
|
||||
if (!OpsEventId.TryParse(id, out var category, out var rowKey))
|
||||
return null;
|
||||
|
||||
var response = await _table.GetEntityIfExistsAsync<TableEntity>(category, rowKey);
|
||||
return response.HasValue ? MapToEntry(response.Value!) : null;
|
||||
}
|
||||
|
||||
public async Task<List<OpsEventEntry>> 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,
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
/// <summary>
|
||||
/// 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<OpsEventEntry>() : new List<OpsEventEntry> { single };
|
||||
return await req.OkAsync(new OpsEventListResponse { Success = true, Count = found.Count, Events = found });
|
||||
}
|
||||
|
||||
var category = query["category"];
|
||||
var filterTenantId = query["tenantId"];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+5
@@ -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<NotificationAction>
|
||||
{
|
||||
new() { Type = "openUrl", Title = "Open hardware whitelist", Url = Constants.PortalBaseUrl + Constants.PortalHardwareWhitelistPath },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// 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. <paramref name="opsEventId"/>
|
||||
/// is the stored row's <see cref="OpsEventId"/>; every alert links to it.
|
||||
/// </summary>
|
||||
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<NotificationAction>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static bool IsSessionUrl([NotNullWhen(true)] string? portalUrl) =>
|
||||
portalUrl != null && portalUrl.StartsWith(Constants.PortalBaseUrl + "/sessions?id=", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Session-bound alerts collapse per session where the browser honours tags (best effort,
|
||||
/// K21); everything else is its own notification.
|
||||
/// </summary>
|
||||
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
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,24 @@ namespace AutopilotMonitor.Shared
|
||||
public static string PortalSessionUrl(string sessionId) =>
|
||||
$"{PortalBaseUrl}/sessions?id={System.Uri.EscapeDataString(sessionId ?? string.Empty)}";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string PortalSessionUrl(string sessionId, string tenantId) =>
|
||||
$"{PortalSessionUrl(sessionId)}&tenantId={System.Uri.EscapeDataString(tenantId ?? string.Empty)}";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string PortalOpsEventUrl(string opsEventId) =>
|
||||
$"{PortalBaseUrl}/admin/ops/?eventId={System.Uri.EscapeDataString(opsEventId ?? string.Empty)}";
|
||||
|
||||
/// <summary>The tenant's hardware whitelist settings, which also list the rejected models.</summary>
|
||||
public const string PortalHardwareWhitelistPath = "/settings/tenant/hardware-whitelist";
|
||||
|
||||
/// <summary>
|
||||
/// The pairing link shown as QR code and typed into the receiver app. The code travels in the
|
||||
/// fragment (never reaches a server log); the trailing slash matches the static export.
|
||||
|
||||
@@ -11,8 +11,18 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// </summary>
|
||||
public interface IOpsEventRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes the event and sets <see cref="OpsEventEntry.Id"/> to the stored row's
|
||||
/// <see cref="OpsEventId"/>, so the caller can link to exactly this row.
|
||||
/// </summary>
|
||||
Task SaveOpsEventAsync(OpsEventEntry entry);
|
||||
|
||||
/// <summary>
|
||||
/// Point read of one event by its <see cref="OpsEventId"/>; null when the id is malformed
|
||||
/// or the row no longer exists (retention).
|
||||
/// </summary>
|
||||
Task<OpsEventEntry?> GetOpsEventAsync(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all matching ops events in the given UTC window. <paramref name="category"/>
|
||||
/// 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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one id shape of an ops event: <c>{Category}_{RowKey}</c>, the RowKey being the 19-digit
|
||||
/// inverted-tick key. The portal deep link (<c>/admin/ops?eventId=</c>) and the
|
||||
/// <c>?id=</c> lookup carry it, so it must stay parseable back to PartitionKey + RowKey.
|
||||
/// </summary>
|
||||
public static class OpsEventId
|
||||
{
|
||||
private const int RowKeyLength = 19;
|
||||
|
||||
public static string Format(string category, string rowKey) => $"{category}_{rowKey}";
|
||||
|
||||
/// <summary>True for a known category followed by a 19-digit RowKey; nothing else is an id.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for operational events.
|
||||
/// </summary>
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title={title}
|
||||
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { CursorPager } from "@/hooks/useCursorPager";
|
||||
|
||||
type PagerControls = Pick<CursorPager<unknown>, "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 (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{count} on page {pager.pageNumber}{filtered ? " (filtered)" : ""}{pager.hasNext ? "" : " (last)"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => void pager.prev()} disabled={!pager.hasPrev || loading} className={BUTTON}>
|
||||
Previous
|
||||
</button>
|
||||
<button onClick={() => void pager.next()} disabled={!pager.hasNext || loading} className={BUTTON}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DistressReportsSection({
|
||||
getAccessToken,
|
||||
setError,
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Suspense fallback={null}>
|
||||
<OpsEventsSectionInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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<OpsEventListResponse>(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({
|
||||
</table>
|
||||
|
||||
{/* Pagination (Pattern B1 — backend-driven) */}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{filteredEvents.length} on page {pager.pageNumber}{searchQuery && ` (filtered)`}{pager.hasNext ? "" : " (last)"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void pager.prev()}
|
||||
disabled={!pager.hasPrev || loading}
|
||||
className="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"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void pager.next()}
|
||||
disabled={!pager.hasNext || loading}
|
||||
className="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"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CursorPagerFooter pager={pager} count={filteredEvents.length} loading={loading} filtered={!!searchQuery} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -501,6 +487,28 @@ export function OpsEventsSection({
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{(() => {
|
||||
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 (
|
||||
<div className="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700 flex flex-wrap gap-2">
|
||||
{tenantUrl && (
|
||||
<Link href={tenantUrl} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700">
|
||||
View tenant
|
||||
</Link>
|
||||
)}
|
||||
{relatedUrl && (
|
||||
<Link href={relatedUrl} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700">
|
||||
Open related page
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const sessionId = extractSessionId(selectedEvent.details ?? null);
|
||||
if (!sessionId) return null;
|
||||
@@ -512,7 +520,7 @@ export function OpsEventsSection({
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={sessionUrl(sessionId)}
|
||||
href={sessionUrl(sessionId, { tenantId: selectedEvent.tenantId ?? undefined })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700"
|
||||
|
||||
@@ -23,6 +23,8 @@ import type {
|
||||
RuleSubmissionItem,
|
||||
RuleSubmissionListResponse,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import { CursorPagerFooter } from "./CursorPagerFooter";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const STATUS_FILTERS = ["pending", "approved", "declined", "withdrawn"] as const;
|
||||
@@ -32,30 +34,6 @@ interface RuleSubmissionsSectionProps {
|
||||
setError: (error: string | null) => 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 (
|
||||
<button onClick={handleCopy} title={title} className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const b = submissionStatusBadge(status);
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${b.cls}`}>{b.label}</span>;
|
||||
@@ -245,13 +223,7 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{items.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => void pager.prev()} disabled={!pager.hasPrev || loading} className="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">Previous</button>
|
||||
<button onClick={() => void pager.next()} disabled={!pager.hasNext || loading} className="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">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
<CursorPagerFooter pager={pager} count={items.length} loading={loading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminNoteEditor({
|
||||
report,
|
||||
canMutate,
|
||||
@@ -424,27 +398,7 @@ function SessionReportsSectionInner({
|
||||
</table>
|
||||
|
||||
{/* Pagination (Pattern B1 — backend-driven) */}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{reports.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void pager.prev()}
|
||||
disabled={!pager.hasPrev || loading}
|
||||
className="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"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void pager.next()}
|
||||
disabled={!pager.hasNext || loading}
|
||||
className="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"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CursorPagerFooter pager={pager} count={reports.length} loading={loading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, unknown>).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.
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user