mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Push: the session watch fires at the WhiteGlove seal
"Notify me when done" now also fires on the first genuine pre-provisioning
seal ("Pre-provisioning finished") on the gate of the Pre-Provisioning
Completed alert, and the watch is consumed there; watching the user phase takes
a new click. A watch placed on a sealed session lives 30 days instead of 48
hours. EventIngestProcessor.WatchOutcomeFor is the one gate, the watch text
comes from PushSessionWatchService.WatchMessage. The portal button re-reads the
watch on every status change and names what it waits for. The Success hint
says it covers a finished pre-provisioning; the receiver labels the
preprovisioning_* alerts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
@@ -15,8 +16,8 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// Session watches ("notify my devices when this session ends"): the watch row lives in the
|
||||
/// SESSION's tenant, the devices it fires on are the watcher's OWN scopes (home tenant, plus the
|
||||
/// platform partition for a Global Admin), gated by an enabled Push channel and the role check at
|
||||
/// watch time and again at notify time. 48-hour lifetime, one-hour push TTL, consumed at the
|
||||
/// terminal status, fail-soft towards the ingest path.
|
||||
/// watch time and again at notify time. 48-hour lifetime (30 days on a sealed session), one-hour
|
||||
/// push TTL, consumed at the terminal status or the WhiteGlove seal, fail-soft towards the ingest path.
|
||||
/// </summary>
|
||||
public class PushSessionWatchServiceTests
|
||||
{
|
||||
@@ -40,7 +41,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.ChannelRequired, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -53,7 +54,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(Home, Admin.Upn, Admin.ObjectId, Admin.HomeTenantId)).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.NotEligible, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -68,7 +69,7 @@ public class PushSessionWatchServiceTests
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId, status);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.Conflict, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -80,7 +81,7 @@ public class PushSessionWatchServiceTests
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Operator, HomeTenantId);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.Conflict, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -93,7 +94,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
Assert.True(result.Ok);
|
||||
@@ -107,13 +108,29 @@ public class PushSessionWatchServiceTests
|
||||
Assert.Equal(row.ExpiresUtc, result.Value.ExpiresUtc!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watch_on_a_sealed_session_lives_thirty_days_because_the_user_phase_starts_whenever_the_device_is_handed_out()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.Pending, Admin);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var row = h.Repo.Watches[(HomeTenantId, SessionId, Admin.ObjectId)];
|
||||
Assert.InRange(row.ExpiresUtc, before.AddDays(30), after.AddDays(30));
|
||||
Assert.Equal(row.ExpiresUtc, result.Value!.ExpiresUtc!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_with_only_a_platform_device_watches_a_customer_session_from_the_platform_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
// The row lives in the SESSION's tenant partition, not in the platform or the home partition.
|
||||
@@ -130,7 +147,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("h1", GlobalAdmin, HomeTenantId);
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal($"{Constants.Push.PlatformScope};{HomeTenantId}", h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
|
||||
@@ -144,7 +161,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal(Constants.Push.PlatformScope, h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
|
||||
@@ -263,7 +280,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("c2", CustomerAdmin, CustomerTenantId);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Equal(Endpoints(p1, p2), SentTo(h));
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -283,12 +300,40 @@ public class PushSessionWatchServiceTests
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: false, "DESKTOP-4711", null);
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", null);
|
||||
|
||||
Assert.Equal(Endpoints(d1, d2), SentTo(h));
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_at_the_seal_pushes_and_consumes_the_watch_so_the_user_phase_needs_a_new_one()
|
||||
{
|
||||
var h = new Harness();
|
||||
var device = h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Sealed, "DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Equal(device.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString());
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionWatchOutcome.Succeeded, "Enrollment finished", "DESKTOP-4711 (…7890) finished enrollment.", "success")]
|
||||
[InlineData(SessionWatchOutcome.Failed, "Enrollment failed", "DESKTOP-4711 (…7890) failed enrollment.", "error")]
|
||||
[InlineData(SessionWatchOutcome.Sealed, "Pre-provisioning finished", "DESKTOP-4711 (…7890) finished pre-provisioning.", "success")]
|
||||
public void Watch_message_names_the_outcome_with_the_device_and_the_masked_serial(SessionWatchOutcome outcome, string title, string body, string severity)
|
||||
{
|
||||
Assert.Equal((title, body, severity), PushSessionWatchService.WatchMessage(outcome, "DESKTOP-4711", "1234567890"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watch_message_without_a_device_name_or_serial_says_the_device()
|
||||
{
|
||||
Assert.Equal("The device finished pre-provisioning.", PushSessionWatchService.WatchMessage(SessionWatchOutcome.Sealed, null, null).Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_with_empty_owner_scopes_falls_back_to_the_session_tenant()
|
||||
{
|
||||
@@ -296,7 +341,7 @@ public class PushSessionWatchServiceTests
|
||||
var device = h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, ownerScopes: string.Empty); // legacy row
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: true, "DESKTOP-4711", null);
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(device.Endpoint, request.RequestUri!.ToString());
|
||||
@@ -310,7 +355,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope, expiresUtc: DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: false, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -323,7 +368,7 @@ public class PushSessionWatchServiceTests
|
||||
repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List<PushSessionWatch>());
|
||||
var h = new Harness(repo.Object);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", null);
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null);
|
||||
|
||||
repo.Verify(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId), Times.Once);
|
||||
repo.VerifyNoOtherCalls();
|
||||
@@ -331,15 +376,16 @@ public class PushSessionWatchServiceTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(bool success)
|
||||
[InlineData(SessionWatchOutcome.Succeeded)]
|
||||
[InlineData(SessionWatchOutcome.Failed)]
|
||||
[InlineData(SessionWatchOutcome.Sealed)]
|
||||
public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(SessionWatchOutcome outcome)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, outcome, "DESKTOP-4711", "1234567890");
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("3600", Header(request, "TTL")); // WatchPushTtl: "Watch bis Session-Ende + 1 h"
|
||||
@@ -354,7 +400,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, GlobalAdmin.Upn, GlobalAdmin.ObjectId, GlobalAdmin.HomeTenantId)).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status);
|
||||
@@ -369,7 +415,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.IsOwnerFreshAsync(GlobalAdmin.HomeTenantId, GlobalAdmin.ObjectId, It.IsAny<DateTime>())).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
|
||||
|
||||
var row = h.Repo.Devices[(Constants.Push.PlatformScope, "p1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
|
||||
@@ -387,7 +433,7 @@ public class PushSessionWatchServiceTests
|
||||
repo.Setup(r => r.GetWatchesForSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
var h = new Harness(repo.Object);
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890"));
|
||||
var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890"));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The ingest gate that decides whether a batch fires the session watches: the terminal status on
|
||||
/// the enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning
|
||||
/// alert's gate. A duplicate seal or a status that did not change fires nothing.
|
||||
/// </summary>
|
||||
public class SessionWatchOutcomeGateTests
|
||||
{
|
||||
private static EnrollmentEvent Event(string type) => new() { EventType = type };
|
||||
|
||||
[Fact]
|
||||
public void A_completion_that_changed_the_status_fires_succeeded()
|
||||
{
|
||||
var c = new EventClassification { CompletionEvent = Event("enrollment_complete") };
|
||||
|
||||
Assert.Equal(SessionWatchOutcome.Succeeded, EventIngestProcessor.WatchOutcomeFor(true, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_with_a_failure_verdict_fires_failed()
|
||||
{
|
||||
var c = new EventClassification { FailureEvent = Event("enrollment_failed") };
|
||||
|
||||
Assert.Equal(SessionWatchOutcome.Failed, EventIngestProcessor.WatchOutcomeFor(true, false, c, "ESP timeout (0x800705b4)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_agent_timeout_that_classified_without_a_failure_reason_fires_nothing()
|
||||
{
|
||||
var c = new EventClassification { FailureEvent = Event("enrollment_failed") };
|
||||
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_terminal_event_without_a_status_change_fires_nothing()
|
||||
{
|
||||
var c = new EventClassification { CompletionEvent = Event("enrollment_complete") };
|
||||
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_genuine_seal_fires_sealed()
|
||||
{
|
||||
var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") };
|
||||
|
||||
Assert.Equal(SessionWatchOutcome.Sealed, EventIngestProcessor.WatchOutcomeFor(false, true, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_duplicate_whiteglove_complete_fires_nothing()
|
||||
{
|
||||
// The agent emits whiteglove_complete twice; the second batch finds the session already
|
||||
// sealed, so whiteGloveStatusTransitioned stays false and the watch is not consumed again.
|
||||
var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") };
|
||||
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_batch_without_terminal_or_seal_events_fires_nothing()
|
||||
{
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, true, new EventClassification(), null));
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,11 @@ public class SessionWatchFunctions
|
||||
if (!PushFunctionSupport.IsPlausibleId(sessionId))
|
||||
return await req.NotFoundAsync("Session not found.");
|
||||
var (tenantId, caller) = await ScopeAsync(req, sessionId);
|
||||
if (await _sessionRepo.GetSessionAsync(tenantId, sessionId) == null)
|
||||
var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
|
||||
if (session == null)
|
||||
return await req.NotFoundAsync("Session not found.");
|
||||
|
||||
var result = await _watches.WatchAsync(tenantId, sessionId, caller);
|
||||
var result = await _watches.WatchAsync(tenantId, sessionId, session.Status, caller);
|
||||
return result.Ok
|
||||
? await req.OkAsync(result.Value!)
|
||||
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message);
|
||||
|
||||
+25
-4
@@ -2,6 +2,7 @@ using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -27,11 +28,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var failureVerdictApplies = c.FailureEvent != null && failureReason != null;
|
||||
var reachedTerminalStatus = statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies);
|
||||
|
||||
// Session watches ("notify me when this session ends") fire on the same terminal gate
|
||||
// as the enrollment alert, independent of any channel configuration. Fail-soft inside.
|
||||
if (reachedTerminalStatus && _pushWatchNotifier != null)
|
||||
// Session watches ("notify me when this session ends") fire on the same gates as the
|
||||
// enrollment and pre-provisioning alerts, independent of any channel configuration.
|
||||
// Fail-soft inside.
|
||||
var watchOutcome = WatchOutcomeFor(statusTransitioned, whiteGloveStatusTransitioned, c, failureReason);
|
||||
if (watchOutcome != null && _pushWatchNotifier != null)
|
||||
{
|
||||
await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, c.CompletionEvent != null,
|
||||
await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, watchOutcome.Value,
|
||||
updatedSession?.DeviceName, updatedSession?.SerialNumber).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -126,6 +129,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a session watch fires on for this batch, or null: the terminal status on the
|
||||
/// enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning
|
||||
/// alert's gate (never the duplicate <c>whiteglove_complete</c>, which leaves
|
||||
/// <paramref name="whiteGloveStatusTransitioned"/> false). The seal consumes the watch.
|
||||
/// </summary>
|
||||
internal static SessionWatchOutcome? WatchOutcomeFor(
|
||||
bool statusTransitioned, bool whiteGloveStatusTransitioned, EventClassification c, string? failureReason)
|
||||
{
|
||||
if (statusTransitioned && c.CompletionEvent != null)
|
||||
return SessionWatchOutcome.Succeeded;
|
||||
if (statusTransitioned && c.FailureEvent != null && failureReason != null)
|
||||
return SessionWatchOutcome.Failed;
|
||||
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null)
|
||||
return SessionWatchOutcome.Sealed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands an alert to the notification-dispatch queue. The send used to be fire-and-forget
|
||||
/// after the HTTP response; the host does not count such work at shutdown, so a Flex
|
||||
|
||||
@@ -6,24 +6,38 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Push;
|
||||
|
||||
/// <summary>Terminal-status hook for the ingest path: fires the pushes of everyone watching the session.</summary>
|
||||
/// <summary>What a watch fires on: the session's terminal status, or the WhiteGlove seal as an intermediate goal.</summary>
|
||||
public enum SessionWatchOutcome
|
||||
{
|
||||
Succeeded,
|
||||
Failed,
|
||||
/// <summary>Pre-provisioning (WhiteGlove part 1) sealed the device; the user phase follows later.</summary>
|
||||
Sealed,
|
||||
}
|
||||
|
||||
/// <summary>Ingest-path hook: fires the pushes of everyone watching the session.</summary>
|
||||
public interface IPushSessionWatchNotifier
|
||||
{
|
||||
Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber);
|
||||
Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Notify my devices when this session ends" (plan use case 3): one watch per (session, person),
|
||||
/// consumed at the terminal status. The watch row lives in the SESSION's tenant (tenant isolation
|
||||
/// of the session), the devices it fires on are the watcher's OWN: their home-tenant scope and,
|
||||
/// for a Global Admin, the platform scope — so an operator can watch a customer's session from
|
||||
/// their own paired phone. Admin and Operator only (F14), only in a scope with an enabled Push
|
||||
/// channel (K4), and the fan-out runs through the same role and freshness filter as any alert.
|
||||
/// Watches expire after 48 hours (the agent's emergency break) and die with the session's tenant.
|
||||
/// consumed at the terminal status — or at the WhiteGlove seal, the moment the technician at the
|
||||
/// bench is waiting for; watching the user phase takes a second click. The watch row lives in the
|
||||
/// SESSION's tenant (tenant isolation of the session), the devices it fires on are the watcher's
|
||||
/// OWN: their home-tenant scope and, for a Global Admin, the platform scope — so an operator can
|
||||
/// watch a customer's session from their own paired phone. Admin and Operator only (F14), only in
|
||||
/// a scope with an enabled Push channel (K4), and the fan-out runs through the same role and
|
||||
/// freshness filter as any alert. Watches expire after 48 hours (the agent's emergency break), a
|
||||
/// watch on a sealed session after 30 days (the user phase starts whenever the device is handed
|
||||
/// out), and die with the session's tenant.
|
||||
/// </summary>
|
||||
public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
{
|
||||
public static readonly TimeSpan WatchLifetime = TimeSpan.FromHours(48);
|
||||
/// <summary>A sealed (Pending) session resumes only when the device reaches its user; matches the owner inactivity window.</summary>
|
||||
public static readonly TimeSpan WatchLifetimeSealed = TimeSpan.FromDays(30);
|
||||
/// <summary>The terminal push waits for an offline phone longer than a system message: plan "Watch bis Session-Ende + 1 h".</summary>
|
||||
public static readonly TimeSpan WatchPushTtl = TimeSpan.FromHours(1);
|
||||
private const char ScopeSeparator = ';';
|
||||
@@ -56,7 +70,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public async Task<PushOpResult<SessionWatchResponse>> WatchAsync(string tenantId, string sessionId, PushCaller caller)
|
||||
public async Task<PushOpResult<SessionWatchResponse>> WatchAsync(string tenantId, string sessionId, SessionStatus sessionStatus, PushCaller caller)
|
||||
{
|
||||
var anyChannel = false;
|
||||
var anyEligible = false;
|
||||
@@ -95,7 +109,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
OwnerUpn = caller.Upn,
|
||||
OwnerScopes = string.Join(ScopeSeparator, usable),
|
||||
CreatedUtc = now,
|
||||
ExpiresUtc = now + WatchLifetime,
|
||||
ExpiresUtc = now + (sessionStatus == SessionStatus.Pending ? WatchLifetimeSealed : WatchLifetime),
|
||||
};
|
||||
await _repo.UpsertWatchAsync(watch).ConfigureAwait(false);
|
||||
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true });
|
||||
@@ -137,7 +151,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
}
|
||||
|
||||
/// <summary>Fail-soft: a watch push must never cost the ingest path anything.</summary>
|
||||
public async Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber)
|
||||
public async Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -146,10 +160,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var device = PushAlertProjector.Clean(deviceName, 48);
|
||||
var serial = PushAlertProjector.MaskSerial(serialNumber);
|
||||
var body = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty)
|
||||
+ (success ? " finished enrollment." : " failed enrollment.");
|
||||
var (title, body, severity) = WatchMessage(outcome, deviceName, serialNumber);
|
||||
|
||||
foreach (var watch in watches)
|
||||
{
|
||||
@@ -161,9 +172,8 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
|
||||
foreach (var scope in ScopesOf(watch, tenantId))
|
||||
{
|
||||
var payload = PushAlertProjector.SystemMessage("session_watch",
|
||||
success ? "Enrollment finished" : "Enrollment failed",
|
||||
body, success ? "success" : "error", scope, now, Constants.PortalSessionUrl(sessionId));
|
||||
var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now,
|
||||
Constants.PortalSessionUrl(sessionId));
|
||||
await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -176,6 +186,20 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The watch push's lock-screen text: device name and masked serial only (D-328).</summary>
|
||||
internal static (string Title, string Body, string Severity) WatchMessage(SessionWatchOutcome outcome, string? deviceName, string? serialNumber)
|
||||
{
|
||||
var device = PushAlertProjector.Clean(deviceName, 48);
|
||||
var serial = PushAlertProjector.MaskSerial(serialNumber);
|
||||
var subject = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty);
|
||||
return outcome switch
|
||||
{
|
||||
SessionWatchOutcome.Succeeded => ("Enrollment finished", subject + " finished enrollment.", "success"),
|
||||
SessionWatchOutcome.Sealed => ("Pre-provisioning finished", subject + " finished pre-provisioning.", "success"),
|
||||
_ => ("Enrollment failed", subject + " failed enrollment.", "error"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<NotificationScope> ScopesOf(PushSessionWatch watch, string sessionTenantId)
|
||||
{
|
||||
var keys = (watch.OwnerScopes ?? string.Empty).Split(ScopeSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
@@ -21,10 +21,12 @@ interface SessionWatchButtonProps {
|
||||
|
||||
/**
|
||||
* "Notify me when done" (plan push-relay): registers a watch that pushes to the caller's own
|
||||
* paired devices when this session reaches a terminal status. Hidden once the session is
|
||||
* terminal; the host gates it to Admins/Operators (and Global Admins) like Report Session.
|
||||
* The GET says whether watching is possible for this person at all (a Push channel in one of
|
||||
* their own scopes); without that the button stays hidden instead of letting a click fail.
|
||||
* paired devices when this session reaches a terminal status — or when pre-provisioning seals
|
||||
* the device, which consumes the watch; on the sealed (Pending) session a new click watches the
|
||||
* user phase. Hidden once the session is terminal; the host gates it to Admins/Operators (and
|
||||
* Global Admins) like Report Session. The GET says whether watching is possible for this person
|
||||
* at all (a Push channel in one of their own scopes); without that the button stays hidden
|
||||
* instead of letting a click fail. Every status change re-reads the watch, so the seal shows here.
|
||||
*/
|
||||
export default function SessionWatchButton({ sessionId, effectiveTenantId, sessionStatus, getAccessToken, addNotification }: SessionWatchButtonProps) {
|
||||
const [watching, setWatching] = useState<boolean | null>(null);
|
||||
@@ -32,6 +34,8 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const active = sessionId.length > 0 && !!sessionStatus && !TERMINAL_STATUSES.has(sessionStatus);
|
||||
const sealed = sessionStatus === "Pending";
|
||||
const until = sealed ? "the user phase ends" : "this session ends or pre-provisioning finishes";
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -51,7 +55,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, sessionId, effectiveTenantId, getAccessToken]);
|
||||
}, [active, sessionStatus, sessionId, effectiveTenantId, getAccessToken]);
|
||||
|
||||
if (!active || !available) return null;
|
||||
|
||||
@@ -63,7 +67,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
const result = await setSessionWatch(sessionId, effectiveTenantId, next, getAccessToken);
|
||||
setWatching(result.watching);
|
||||
if (result.watching) {
|
||||
addNotification("success", TOAST_TITLE, "Your paired devices will be notified when this session ends.", TOAST_KEY);
|
||||
addNotification("success", TOAST_TITLE, `Your paired devices will be notified when ${until}.`, TOAST_KEY);
|
||||
} else {
|
||||
addNotification("info", TOAST_TITLE, "You will not be notified for this session.", TOAST_KEY);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
onClick={() => void toggle()}
|
||||
disabled={busy || watching === null}
|
||||
aria-pressed={watching === true}
|
||||
title={watching ? "Stop notifying my devices when this session ends." : "Push to my paired devices when this session ends."}
|
||||
title={watching ? `Stop notifying my devices when ${until}.` : `Push to my paired devices when ${until}.`}
|
||||
className={`px-4 py-2 rounded-md transition-colors flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
watching
|
||||
? "bg-sky-100 border border-sky-300 text-sky-800 hover:bg-sky-200"
|
||||
|
||||
@@ -55,7 +55,7 @@ export const PROVIDERS: {
|
||||
|
||||
const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [
|
||||
{ key: "notifyOnStart", label: "Start", hint: "Enrollment started (one signal per fresh session and per Pre-Provisioning resume)" },
|
||||
{ key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully" },
|
||||
{ key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully, or pre-provisioning finished" },
|
||||
{ key: "notifyOnFailure", label: "Failure", hint: "Enrollment failed" },
|
||||
{ key: "notifyOnHardwareRejection", label: "Hardware rejection", hint: "Device rejected by the hardware whitelist" },
|
||||
{ key: "notifyOnSlaEvents", label: "SLA", hint: "SLA breach / resolved / consecutive failures (which breaches are evaluated is configured under SLA Targets)" },
|
||||
|
||||
@@ -44,6 +44,8 @@ describe("classifyEntry", () => {
|
||||
expect(classifyEntry(entry("b", "X", "platform", [{ name: "Category", value: "Consent" }]))).toEqual({ group: "platform", kind: "consent", label: "Consent" });
|
||||
expect(classifyEntry(entry("c", "test", "platform"))).toEqual({ group: "platform", kind: "other", label: "Other" });
|
||||
expect(classifyEntry(entry("d", "enrollment_succeeded"))).toEqual({ group: "enrollment", kind: "enrollment_succeeded", label: "Enrollment succeeded" });
|
||||
// The WhiteGlove seal travels on the Success toggle as its own alert kind.
|
||||
expect(classifyEntry(entry("f", "preprovisioning_completed"))).toEqual({ group: "enrollment", kind: "preprovisioning_completed", label: "Pre-provisioning completed" });
|
||||
expect(classifyEntry(entry("e", "some_new_kind"))).toEqual({ group: "enrollment", kind: "some_new_kind", label: "Some new kind" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,9 @@ const ENROLLMENT_LABELS: Record<string, string> = {
|
||||
enrollment_failed: "Enrollment failed",
|
||||
enrollment_complete: "Enrollment complete",
|
||||
enrollment_end: "Enrollment ended",
|
||||
preprovisioning_completed: "Pre-provisioning completed",
|
||||
preprovisioning_resumed: "Pre-provisioning resumed",
|
||||
preprovisioning_failed: "Pre-provisioning failed",
|
||||
session_stalled: "Session stalled",
|
||||
session_watch: "Session watch",
|
||||
consecutive_failures: "Consecutive failures",
|
||||
|
||||
Reference in New Issue
Block a user