feat: a session the platform blocked says so (D-359)

When the event limit or the platform operator blocks a session's device, the
session now carries a block record: Block* fields on the session and its index
row, a server-authored device_blocked timeline event, an interim analyze run for
that event and, on the auto path, an audit entry. The block list itself stays
platform-only.

- Portal: a card above the failure snapshot says the monitoring was paused
  (orange) or ended (red), when, until when and, for the event limit, the
  session's event count against the limit. The Blocked pill reads the record for
  every role; Global Admin mode still adds the live block list.
- A Kill is device-wide until it expires, so its record carries the end and the
  card says new enrollments on the device are not monitored until then.
- Status: the stalled and timeout reasons name the block instead of a silent
  agent; the classification is unchanged.
- Rules: ANALYZE-ENRL-006 explains the pause right after it (on_event
  device_blocked); ANALYZE-ENRL-002 no longer fires for a blocked session.
- MCP: the coverage block of get_session_summary names the block as a gap.
- The agent-facing block message no longer says "by an administrator".
- BlockCause == event_limit replaces ExcessiveEventsAutoActioned as the
  auto-action's idempotency marker.
- Privacy page: the event limit next to rate limiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-14 16:15:16 +02:00
parent d5a83374d0
commit 7a917eeaee
41 changed files with 1535 additions and 69 deletions
+7 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the server-side inactivity timeout closed the enrollment session (no agent telemetry for the configured SessionTimeoutHours window) before it ever completed or explicitly failed.",
"severity": "high",
"category": "enrollment",
"version": "1.3.0",
"version": "1.4.0",
"enabled": true,
"baseConfidence": 80,
"preconditions": [
@@ -20,6 +20,12 @@
"eventType": "enrollment_failed",
"operator": "not_exists",
"description": "Suppress when an explicit enrollment_failed exists — that path is already covered by ANALYZE-ENRL-001; avoid double-reporting the same session."
},
{
"source": "event_data",
"eventType": "device_blocked",
"operator": "not_exists",
"description": "Suppress when the platform blocked the session's device: the silence is the refused uploads, which ANALYZE-ENRL-006 explains — 'the agent died or the device went offline' would be wrong."
}
],
"conditions": [
+107
View File
@@ -0,0 +1,107 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ENRL-006",
"title": "Monitoring Paused by a Protective Limit",
"description": "Detects when the platform blocked the session's device — automatically after an unusually high event volume, or by the platform operator — so the session's telemetry stopped reaching Autopilot Monitor.",
"severity": "warning",
"category": "enrollment",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"on_event:device_blocked"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 50,
"conditions": [
{
"signal": "device_blocked",
"source": "event_type",
"eventType": "device_blocked",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "event_count",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "eventCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "event_limit",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "threshold",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "blocked_until",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "blockedUntil",
"operator": "exists",
"value": "",
"required": false
}
],
"explanation": "Autopilot Monitor stopped accepting telemetry from this session. A protective limit keeps a single device that sends far more events than an enrollment normally does from distorting your fleet data or overloading the platform. When a session crosses it, the device's uploads are paused for a limited time; in severe cases the agent ends monitoring and removes itself. The platform operator can apply the same pause by hand.\n\nA pause does not affect the enrollment itself: the agent keeps running and only holds back its uploads, so Windows Autopilot and Intune carry on as usual. While the pause lasts, no events from this session reach Autopilot Monitor, so the timeline and the final status reflect the last state the agent reported before it. A paused session does not affect the device's next enrollment, which starts unblocked. When the agent ended monitoring, the device stays unmonitored until the end time shown on the session page, new enrollments included.",
"remediation": [
{
"title": "Find what made the session so busy",
"steps": [
"Open the event timeline and look at which event type repeats most often before the pause — for example a network connection that drops and reconnects every few minutes, or a download that reports progress continuously",
"Fix the underlying condition (for example a loose dock, cable or Wi-Fi), then re-enroll the device if you need a complete record of the enrollment",
"If no event type stands out, report the session from its page so it can be looked at"
]
},
{
"title": "What happens next",
"steps": [
"A pause ends on its own at the time shown on the session page; an agent that is still running then resumes uploading",
"After a pause, a new enrollment on this device starts unblocked",
"After the agent ended monitoring, wait for the end time shown on the session page before re-enrolling if you want the new enrollment monitored"
]
}
],
"relatedDocs": [
{
"title": "Autopilot Monitor: Protective Limits",
"url": "https://docs.autopilotmonitor.com/concepts/protective-limits"
}
],
"presentation": {
"role": "outcome",
"summary": "Monitoring was paused by a protective limit",
"facts": [
{
"label": "Paused at",
"value": "{{device_blocked.timestamp}}",
"format": "timestamp"
},
{
"label": "Events in this session",
"value": "{{event_count.value}}",
"format": "number"
},
{
"label": "Event limit",
"value": "{{event_limit.value}}",
"format": "number"
},
{
"label": "Paused until",
"value": "{{blocked_until.value}}",
"format": "timestamp"
}
]
},
"tags": ["enrollment", "protective-limit", "blocked"]
}
+117 -1
View File
@@ -3006,7 +3006,7 @@
"description": "Detects when the server-side inactivity timeout closed the enrollment session (no agent telemetry for the configured SessionTimeoutHours window) before it ever completed or explicitly failed.",
"severity": "high",
"category": "enrollment",
"version": "1.3.0",
"version": "1.4.0",
"enabled": true,
"baseConfidence": 80,
"preconditions": [
@@ -3021,6 +3021,12 @@
"eventType": "enrollment_failed",
"operator": "not_exists",
"description": "Suppress when an explicit enrollment_failed exists — that path is already covered by ANALYZE-ENRL-001; avoid double-reporting the same session."
},
{
"source": "event_data",
"eventType": "device_blocked",
"operator": "not_exists",
"description": "Suppress when the platform blocked the session's device: the silence is the refused uploads, which ANALYZE-ENRL-006 explains — 'the agent died or the device went offline' would be wrong."
}
],
"conditions": [
@@ -3453,6 +3459,116 @@
"esp"
]
},
{
"ruleId": "ANALYZE-ENRL-006",
"title": "Monitoring Paused by a Protective Limit",
"description": "Detects when the platform blocked the session's device — automatically after an unusually high event volume, or by the platform operator — so the session's telemetry stopped reaching Autopilot Monitor.",
"severity": "warning",
"category": "enrollment",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"on_event:device_blocked"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 50,
"conditions": [
{
"signal": "device_blocked",
"source": "event_type",
"eventType": "device_blocked",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "event_count",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "eventCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "event_limit",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "threshold",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "blocked_until",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "blockedUntil",
"operator": "exists",
"value": "",
"required": false
}
],
"explanation": "Autopilot Monitor stopped accepting telemetry from this session. A protective limit keeps a single device that sends far more events than an enrollment normally does from distorting your fleet data or overloading the platform. When a session crosses it, the device's uploads are paused for a limited time; in severe cases the agent ends monitoring and removes itself. The platform operator can apply the same pause by hand.\n\nA pause does not affect the enrollment itself: the agent keeps running and only holds back its uploads, so Windows Autopilot and Intune carry on as usual. While the pause lasts, no events from this session reach Autopilot Monitor, so the timeline and the final status reflect the last state the agent reported before it. A paused session does not affect the device's next enrollment, which starts unblocked. When the agent ended monitoring, the device stays unmonitored until the end time shown on the session page, new enrollments included.",
"remediation": [
{
"title": "Find what made the session so busy",
"steps": [
"Open the event timeline and look at which event type repeats most often before the pause — for example a network connection that drops and reconnects every few minutes, or a download that reports progress continuously",
"Fix the underlying condition (for example a loose dock, cable or Wi-Fi), then re-enroll the device if you need a complete record of the enrollment",
"If no event type stands out, report the session from its page so it can be looked at"
]
},
{
"title": "What happens next",
"steps": [
"A pause ends on its own at the time shown on the session page; an agent that is still running then resumes uploading",
"After a pause, a new enrollment on this device starts unblocked",
"After the agent ended monitoring, wait for the end time shown on the session page before re-enrolling if you want the new enrollment monitored"
]
}
],
"relatedDocs": [
{
"title": "Autopilot Monitor: Protective Limits",
"url": "https://docs.autopilotmonitor.com/concepts/protective-limits"
}
],
"presentation": {
"role": "outcome",
"summary": "Monitoring was paused by a protective limit",
"facts": [
{
"label": "Paused at",
"value": "{{device_blocked.timestamp}}",
"format": "timestamp"
},
{
"label": "Events in this session",
"value": "{{event_count.value}}",
"format": "number"
},
{
"label": "Event limit",
"value": "{{event_limit.value}}",
"format": "number"
},
{
"label": "Paused until",
"value": "{{blocked_until.value}}",
"format": "timestamp"
}
]
},
"tags": [
"enrollment",
"protective-limit",
"blocked"
]
},
{
"ruleId": "ANALYZE-ESP-001",
"title": "ESP Blocking App Timeout",
@@ -0,0 +1,122 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The shipped rules on a session the platform blocked, driven through the real RuleEngine with
/// the embedded rule JSON: ANALYZE-ENRL-006 explains the block — right after it, through the
/// interim run the block recorder enqueues — and ANALYZE-ENRL-002 no longer blames a dead agent
/// or an offline device for the silence the block caused.
/// </summary>
public class BlockedSessionRuleTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
[Fact]
public void BlockRule_RunsRightAfterTheBlock()
{
var rule = Rule("ANALYZE-ENRL-006");
Assert.True(rule.Enabled);
Assert.Contains("on_event:device_blocked", rule.EvaluateOn!);
Assert.Contains("enrollment_end", rule.EvaluateOn!);
}
[Fact]
public async Task BlockRule_Fires_OnTheBlockEvent()
{
var outcome = await RunAsync("ANALYZE-ENRL-006", new List<EnrollmentEvent> { DeviceBlocked(eventLimit: true) });
Assert.Contains(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-006");
}
[Fact]
public async Task BlockRule_Fires_ForAnOperatorBlockWithoutCounts()
{
var outcome = await RunAsync("ANALYZE-ENRL-006", new List<EnrollmentEvent> { DeviceBlocked(eventLimit: false) });
Assert.Contains(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-006");
}
[Fact]
public async Task BlockRule_Silent_WithoutABlock()
{
var outcome = await RunAsync("ANALYZE-ENRL-006", new List<EnrollmentEvent> { SessionTimeout() });
Assert.DoesNotContain(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-006");
}
[Fact]
public async Task TimeoutRule_Suppressed_WhenThePlatformBlockedTheSession()
{
var outcome = await RunAsync("ANALYZE-ENRL-002", new List<EnrollmentEvent> { DeviceBlocked(eventLimit: true), SessionTimeout() });
Assert.DoesNotContain(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-002");
}
[Fact]
public async Task TimeoutRule_StillFires_WithoutABlock()
{
var outcome = await RunAsync("ANALYZE-ENRL-002", new List<EnrollmentEvent> { SessionTimeout() });
Assert.Contains(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-002");
}
// ===== Helpers =====
private static AnalyzeRule Rule(string ruleId)
{
var rule = BuiltInAnalyzeRules.GetAll().FirstOrDefault(r => r.RuleId == ruleId);
Assert.NotNull(rule);
return rule!;
}
private static EnrollmentEvent DeviceBlocked(bool eventLimit)
{
var record = new SessionBlockRecord
{
BlockedAt = new DateTime(2026, 10, 14, 12, 0, 0, DateTimeKind.Utc),
Action = DeviceBlockActions.Block,
BlockedUntil = new DateTime(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc),
Cause = eventLimit ? SessionBlockCauses.EventLimit : SessionBlockCauses.Operator,
EventCount = eventLimit ? 2553 : null,
EventThreshold = eventLimit ? 2500 : null,
};
var session = new SessionSummary { TenantId = TenantId, SessionId = SessionId };
return SessionBlockRecorder.BuildDeviceBlockedEvent(session, record, new List<EnrollmentEvent>());
}
private static EnrollmentEvent SessionTimeout() => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "session_timeout",
Source = "System.Maintenance",
Severity = EventSeverity.Error,
Timestamp = new DateTime(2026, 10, 14, 17, 0, 0, DateTimeKind.Utc),
Sequence = 100,
Data = new Dictionary<string, object> { ["timeoutHours"] = 5, ["source"] = "maintenance_sweep" }
};
private static async Task<AnalysisOutcome> RunAsync(string ruleId, List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { Rule(ruleId) });
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<BlockedSessionRuleTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -230,7 +230,8 @@ public class FreeTextNameQueryParamTests
NullLogger<DeviceBlockFunction>.Instance,
new BlockedDeviceService(securityRepo, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance),
maintenance.Object,
opsEvents);
opsEvents,
new SessionBlockRecorder(Mock.Of<ISessionRepository>(), Mock.Of<AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer>(), NullLogger<SessionBlockRecorder>.Instance));
}
private static JsonElement Json(HttpResponseData res)
@@ -0,0 +1,269 @@
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Analyze;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The session's record of a device block — the tenant's only view of it: the event the
/// timeline shows, the writer that must never undo or fail the block, the status reason of the
/// session it silenced, and which sessions an operator block lands on.
/// </summary>
public class SessionBlockRecorderTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private const string Serial = "SN-0001";
private static readonly DateTime BlockedAt = new(2026, 10, 14, 12, 0, 0, DateTimeKind.Utc);
private static SessionSummary Session(string serial = Serial) => new()
{
TenantId = TenantId,
SessionId = SessionId,
SerialNumber = serial,
StartedAt = BlockedAt.AddHours(-14),
};
private static SessionBlockRecord EventLimit(string action) =>
MaintenanceService.BuildEventLimitBlockRecord(action, BlockedAt, durationHours: 24, eventCount: 2553, threshold: 2500);
private static SessionBlockRecord Operator(string action, int hours = 12) => new()
{
BlockedAt = BlockedAt,
Action = action,
BlockedUntil = BlockedAt.AddHours(hours),
Cause = SessionBlockCauses.Operator,
};
private static List<EnrollmentEvent> Events(params long[] sequences) =>
sequences.Select(s => new EnrollmentEvent { SessionId = SessionId, TenantId = TenantId, Sequence = s }).ToList();
// ── The timeline event ───────────────────────────────────────────────────
[Fact]
public void EventLimitBlock_ExplainsVolumeAndWindow_AndSortsLast()
{
var evt = SessionBlockRecorder.BuildDeviceBlockedEvent(Session(), EventLimit(DeviceBlockActions.Block), Events(3, 2553, 17));
Assert.Equal(Constants.EventTypes.DeviceBlocked, evt.EventType);
Assert.Equal("Monitoring paused: this session sent 2,553 events (limit 2,500), so the device's uploads are blocked for 24 h.", evt.Message);
Assert.Equal(EventSeverity.Warning, evt.Severity);
Assert.Equal("System.Maintenance", evt.Source);
Assert.Equal(2554, evt.Sequence);
Assert.Equal(BlockedAt, evt.Timestamp);
Assert.Equal(DeviceBlockActions.Block, evt.Data["action"]);
Assert.Equal(SessionBlockCauses.EventLimit, evt.Data["cause"]);
Assert.Equal(BlockedAt.AddHours(24).ToString("o"), evt.Data["blockedUntil"]);
Assert.Equal(2553, evt.Data["eventCount"]);
Assert.Equal(2500, evt.Data["threshold"]);
}
[Fact]
public void EventLimitKill_SaysTheAgentLeaves_AndTheDeviceStaysUnmonitored()
{
// A Kill is device-wide until it expires: a new enrollment's agent gets the kill signal too,
// so the record must carry the end like a Block does.
var record = EventLimit(DeviceBlockActions.Kill);
var evt = SessionBlockRecorder.BuildDeviceBlockedEvent(Session(), record, Events(1));
Assert.Equal(BlockedAt.AddHours(24), record.BlockedUntil);
Assert.Equal("Monitoring ended: this session sent 2,553 events (limit 2,500); the agent removes itself and the device stays unmonitored for 24 h.", evt.Message);
Assert.Equal(BlockedAt.AddHours(24).ToString("o"), evt.Data["blockedUntil"]);
}
[Fact]
public void OperatorBlock_NamesNoVolume_EvenWhenCountsAreSet()
{
var record = Operator(DeviceBlockActions.Block);
record.EventCount = 9999;
record.EventThreshold = 2500;
var evt = SessionBlockRecorder.BuildDeviceBlockedEvent(Session(), record, Events());
Assert.Equal("Monitoring paused by the platform operator for 12 h.", evt.Message);
Assert.Equal("System.Operator", evt.Source);
Assert.Equal(1, evt.Sequence);
Assert.False(evt.Data.ContainsKey("eventCount"));
Assert.False(evt.Data.ContainsKey("threshold"));
}
[Fact]
public void OperatorKill_SaysTheAgentLeaves()
{
var evt = SessionBlockRecorder.BuildDeviceBlockedEvent(Session(), Operator(DeviceBlockActions.Kill), Events(5));
Assert.Equal("Monitoring ended by the platform operator; the agent removes itself and the device stays unmonitored for 12 h.", evt.Message);
}
// ── The writer ───────────────────────────────────────────────────────────
private static Mock<ISessionRepository> Repo(SessionSummary? session, bool recordWrites = true)
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.GetSessionAsync(TenantId, SessionId)).ReturnsAsync(session);
repo.Setup(r => r.RecordSessionBlockAsync(TenantId, SessionId, It.IsAny<SessionBlockRecord>())).ReturnsAsync(recordWrites);
repo.Setup(r => r.GetSessionEventsAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(Events(1, 2));
repo.Setup(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>()))
.ReturnsAsync((List<EnrollmentEvent> e) => e);
return repo;
}
private static SessionBlockRecorder Recorder(Mock<ISessionRepository> repo, Mock<IAnalyzeOnEnrollmentEndProducer>? producer = null) =>
new(repo.Object, (producer ?? new Mock<IAnalyzeOnEnrollmentEndProducer>()).Object, NullLogger<SessionBlockRecorder>.Instance);
[Fact]
public async Task Record_WritesFieldsThenTimelineEvent_ThenAnInterimAnalyzeRun()
{
var repo = Repo(Session());
var producer = new Mock<IAnalyzeOnEnrollmentEndProducer>();
await Recorder(repo, producer).RecordAsync(TenantId, SessionId, Serial, EventLimit(DeviceBlockActions.Block));
repo.Verify(r => r.RecordSessionBlockAsync(TenantId, SessionId,
It.Is<SessionBlockRecord>(b => b.Cause == SessionBlockCauses.EventLimit && b.EventCount == 2553)), Times.Once);
repo.Verify(r => r.StoreEventsBatchAsync(It.Is<List<EnrollmentEvent>>(e =>
e.Count == 1 && e[0].EventType == Constants.EventTypes.DeviceBlocked && e[0].Sequence == 3)), Times.Once);
repo.Verify(r => r.UpsertEventTypeIndexBatchAsync(TenantId, SessionId, It.IsAny<IEnumerable<EnrollmentEvent>>()), Times.Once);
repo.Verify(r => r.ReconcileSessionCountersAsync(TenantId, SessionId), Times.Once);
// The event bypasses ingest, so nothing else would start the on_event:device_blocked rules.
producer.Verify(p => p.EnqueueAsync(It.Is<AnalyzeOnEnrollmentEndEnvelope>(e =>
e.SessionId == SessionId && e.Reason == AnalyzeOnEnrollmentEndHandler.ReasonInterimTrigger
&& e.TriggerEventTypes != null && e.TriggerEventTypes.Single() == Constants.EventTypes.DeviceBlocked),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Record_SerialComparisonIgnoresCaseAndPadding()
{
var repo = Repo(Session(" sn-0001 "));
await Recorder(repo).RecordAsync(TenantId, SessionId, Serial, EventLimit(DeviceBlockActions.Block));
repo.Verify(r => r.RecordSessionBlockAsync(TenantId, SessionId, It.IsAny<SessionBlockRecord>()), Times.Once);
}
[Fact]
public async Task Record_SessionOfAnotherDevice_WritesNothing()
{
// The session id comes from the caller (an operator's form): it must not stamp a block
// onto a session the blocked device never ran.
var repo = Repo(Session("SN-OTHER"));
await Recorder(repo).RecordAsync(TenantId, SessionId, Serial, Operator(DeviceBlockActions.Block));
repo.Verify(r => r.RecordSessionBlockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<SessionBlockRecord>()), Times.Never);
repo.Verify(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>()), Times.Never);
}
[Fact]
public async Task Record_UnknownSession_WritesNothing()
{
var repo = Repo(session: null);
await Recorder(repo).RecordAsync(TenantId, SessionId, Serial, Operator(DeviceBlockActions.Block));
repo.Verify(r => r.RecordSessionBlockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<SessionBlockRecord>()), Times.Never);
repo.Verify(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>()), Times.Never);
}
[Fact]
public async Task Record_FieldWriteFailed_StoresNoEvent()
{
// The card and the timeline must tell the same story: no event without the record.
var repo = Repo(Session(), recordWrites: false);
await Recorder(repo).RecordAsync(TenantId, SessionId, Serial, EventLimit(DeviceBlockActions.Block));
repo.Verify(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>()), Times.Never);
}
[Fact]
public async Task Record_StorageFailure_NeverThrows()
{
// Called after the block is in force — a throw here would abort the sweep's tenant loop
// or fail the operator's block request although the block already landed.
var repo = Repo(Session());
repo.Setup(r => r.StoreEventsBatchAsync(It.IsAny<List<EnrollmentEvent>>())).ThrowsAsync(new InvalidOperationException("storage down"));
await Recorder(repo).RecordAsync(TenantId, SessionId, Serial, EventLimit(DeviceBlockActions.Block));
}
// ── The status reason of the silenced session ────────────────────────────
private static SessionSummary Blocked(string action, string cause, DateTime? lastIngestAt)
{
var s = Session();
s.BlockedAt = BlockedAt;
s.BlockAction = action;
s.BlockCause = cause;
s.BlockedUntil = BlockedAt.AddHours(24);
s.LastIngestAt = lastIngestAt;
return s;
}
[Fact]
public void Reason_NeverBlocked_IsNull()
{
Assert.Null(MaintenanceService.DescribeBlockedMonitoring(Session()));
}
[Fact]
public void Reason_EventLimitBlock()
{
Assert.Equal("Monitoring paused by the platform's event limit",
MaintenanceService.DescribeBlockedMonitoring(Blocked(DeviceBlockActions.Block, SessionBlockCauses.EventLimit, BlockedAt.AddMinutes(-65))));
}
[Fact]
public void Reason_OperatorKill()
{
Assert.Equal("Monitoring ended by the platform operator",
MaintenanceService.DescribeBlockedMonitoring(Blocked(DeviceBlockActions.Kill, SessionBlockCauses.Operator, BlockedAt.AddMinutes(-5))));
}
[Fact]
public void Reason_AgentCameBackAfterTheBlock_IsNotTheBlock()
{
// A still-running agent resumes uploading once the block ends; if it goes silent later,
// that silence has its own cause.
Assert.Null(MaintenanceService.DescribeBlockedMonitoring(
Blocked(DeviceBlockActions.Block, SessionBlockCauses.EventLimit, BlockedAt.AddHours(25))));
}
// ── Which sessions an operator block lands on ────────────────────────────
[Fact]
public void Operator_NamedSession_IsRecorded()
{
Assert.Equal(new[] { SessionId },
DeviceBlockFunction.SessionsToRecord(SessionId, DeviceBlockActions.Block, alreadyBlocked: false, existingBlockedSessionIds: null));
}
[Fact]
public void Operator_WholeDeviceBlock_IsRecordedNowhere()
{
Assert.Empty(DeviceBlockFunction.SessionsToRecord(null, DeviceBlockActions.Block, alreadyBlocked: true, existingBlockedSessionIds: "a,b"));
}
[Fact]
public void Operator_KillUpgrade_UpdatesTheSessionsTheBlockNamed()
{
Assert.Equal(new[] { "a", "b" },
DeviceBlockFunction.SessionsToRecord(null, DeviceBlockActions.Kill, alreadyBlocked: true, existingBlockedSessionIds: "a, b,,A"));
}
[Theory]
[InlineData(false, "a")] // nothing to upgrade
[InlineData(true, null)] // the existing block already covers the whole device
public void Operator_KillWithoutSessionScope_IsRecordedNowhere(bool alreadyBlocked, string? existing)
{
Assert.Empty(DeviceBlockFunction.SessionsToRecord(null, DeviceBlockActions.Kill, alreadyBlocked, existing));
}
}
@@ -76,7 +76,6 @@ public class SessionIndexFieldManifestTests
["RemediationScriptCount"] = 3,
["RebootCount"] = 4,
["ExcessiveEventsAlerted"] = true,
["ExcessiveEventsAutoActioned"] = true,
["CompletedAt"] = new DateTimeOffset(StartedAt.AddMinutes(45)),
["FailureReason"] = "esp timeout",
["FailureSource"] = "ime",
@@ -102,6 +101,12 @@ public class SessionIndexFieldManifestTests
["AvgApiLatencyMs"] = 513.5,
["ApiRequestCount"] = 100,
["ConnectionType"] = "Ethernet",
["BlockedAt"] = new DateTimeOffset(StartedAt.AddMinutes(30)),
["BlockAction"] = "Block",
["BlockedUntil"] = new DateTimeOffset(StartedAt.AddHours(24)),
["BlockCause"] = "event_limit",
["BlockEventCount"] = 2553,
["BlockEventThreshold"] = 2500,
};
// ── Manifest shape ───────────────────────────────────────────────────────────
@@ -40,7 +40,10 @@ public class SessionIndexProjectionTests
["RemediationScriptCount"] = 3,
["RebootCount"] = 4,
["ExcessiveEventsAlerted"] = true,
["ExcessiveEventsAutoActioned"] = true,
// Block record (RecordSessionBlockAsync merges it) — the session list renders the Blocked pill from it.
["BlockedAt"] = new DateTimeOffset(StartedAt.AddMinutes(30)),
["BlockAction"] = "Kill",
["BlockCause"] = "operator",
// Self-deploying/kiosk profile marker (session 320b3bf7) — search-filterable.
["IsSelfDeployingProfile"] = true,
// W365 Cloud PC marker (agent-detected, sticky-true) — search-filterable.
@@ -72,7 +75,9 @@ public class SessionIndexProjectionTests
// index), so it MUST survive a StartedAt-shift full upsert.
Assert.Equal(4, idx.GetInt32("RebootCount"));
Assert.True(idx.GetBoolean("ExcessiveEventsAlerted"));
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
Assert.True(idx.GetDateTimeOffset("BlockedAt").HasValue);
Assert.Equal("Kill", idx.GetString("BlockAction"));
Assert.Equal("operator", idx.GetString("BlockCause"));
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
Assert.True(idx.GetBoolean("IsCloudPc"));
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
@@ -90,6 +90,9 @@ public sealed class SharedManifestParityTests
["annotationVerdicts"] = AnnotationVerdicts.All,
// Install channels of the app rows (AppInstallSummary.Source and the per-app DTOs).
["appInstallSources"] = AppInstallSources.All,
// A session's block record (SessionSummary.BlockAction / BlockCause).
["deviceBlockActions"] = DeviceBlockActions.All,
["sessionBlockCauses"] = SessionBlockCauses.All,
// Event sources whose rows never set a session's start; the portal's session duration applies them.
["sessionAnchorIneligibleSources"] = Constants.EventSources.SessionAnchorIneligible,
// How an OOBE quality update ended (OsUpdateSpan.Outcome in the time attribution).
@@ -197,8 +197,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task MarkExcessiveEventsAlertedAsync(string tenantId, string sessionId)
=> _storage.MarkExcessiveEventsAlertedAsync(tenantId, sessionId);
public Task MarkExcessiveEventsAutoActionedAsync(string tenantId, string sessionId)
=> _storage.MarkExcessiveEventsAutoActionedAsync(tenantId, sessionId);
public Task<bool> RecordSessionBlockAsync(string tenantId, string sessionId, SessionBlockRecord record)
=> _storage.RecordSessionBlockAsync(tenantId, sessionId, record);
public Task<ImeVersionSighting> RecordImeVersionAsync(string version, string tenantId, string sessionId)
=> _storage.RecordImeVersionAsync(version, tenantId, sessionId);
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
@@ -17,17 +19,20 @@ namespace AutopilotMonitor.Functions.Functions.Admin
private readonly BlockedDeviceService _blockedDeviceService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
private readonly SessionBlockRecorder _sessionBlockRecorder;
public DeviceBlockFunction(
ILogger<DeviceBlockFunction> logger,
BlockedDeviceService blockedDeviceService,
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService)
OpsEventService opsEventService,
SessionBlockRecorder sessionBlockRecorder)
{
_logger = logger;
_blockedDeviceService = blockedDeviceService;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
_sessionBlockRecorder = sessionBlockRecorder;
}
/// <summary>GET /api/devices/blocked?tenantId={tenantId} — list all active blocks for a tenant</summary>
@@ -89,9 +94,25 @@ namespace AutopilotMonitor.Functions.Functions.Admin
// Normalize casing
action = string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase) ? "Kill" : "Block";
// Read before the block: a Kill without a session widens the block to the whole device
// and drops the session list it replaces.
var (alreadyBlocked, _, _, existingSessionIds) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumber);
var blockedAt = DateTime.UtcNow;
await _blockedDeviceService.BlockDeviceAsync(
tenantId, serialNumber, durationHours, userIdentifier, reason, action, blockedSessionId);
// The operator's reason stays on the platform side; the session record carries only the cause.
var record = new SessionBlockRecord
{
BlockedAt = blockedAt,
Action = action,
BlockedUntil = blockedAt.AddHours(durationHours),
Cause = SessionBlockCauses.Operator,
};
foreach (var sessionId in SessionsToRecord(blockedSessionId, action, alreadyBlocked, existingSessionIds))
await _sessionBlockRecorder.RecordAsync(tenantId, sessionId, serialNumber, record);
var auditFields = new Dictionary<string, string>
{
{ "Action", action },
@@ -188,6 +209,26 @@ namespace AutopilotMonitor.Functions.Functions.Admin
/// <summary>
/// Trims and rejects whitespace/empty sessionIds so the service layer never receives " " as a valid session token.
/// </summary>
/// <summary>
/// The sessions an operator block is recorded on: the named session, or — for a Kill that
/// widens an existing session block to the whole device — the sessions that block named.
/// A whole-device Block names no session and is recorded nowhere.
/// </summary>
internal static IReadOnlyList<string> SessionsToRecord(
string? blockedSessionId, string action, bool alreadyBlocked, string? existingBlockedSessionIds)
{
if (blockedSessionId != null)
return new[] { blockedSessionId };
if (action != DeviceBlockActions.Kill || !alreadyBlocked || string.IsNullOrWhiteSpace(existingBlockedSessionIds))
return Array.Empty<string>();
return existingBlockedSessionIds!
.Split(',')
.Select(id => id.Trim())
.Where(id => id.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
internal static string? NormalizeOptionalSessionId(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
@@ -329,6 +329,7 @@ builder.Services.AddHttpClient(LogQuerySourceCatalog.HttpClientName, c => c.Time
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Ime.ImePatternHealthService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.SessionOwnerBindingObserver>();
builder.Services.AddSingleton<BlockedDeviceService>();
builder.Services.AddSingleton<SessionBlockRecorder>();
builder.Services.AddSingleton<HardwareRejectionThrottleService>();
builder.Services.AddSingleton<BlockedVersionService>();
builder.Services.AddSingleton<KillSwitchEvaluator>();
@@ -134,7 +134,7 @@ namespace AutopilotMonitor.Functions.Services
isBlocked: true, isKill: isKill, unblockAt: unblockAt,
message: isKill
? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.",
: "Device is temporarily blocked by the platform.",
blockedSessionIds: isKill ? null : blockedSessionIds,
blockedSerial: blockedSerial,
identityBinding: identityBinding);
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly TenantConfigurationService _tenantConfigService;
private readonly AdminConfigurationService _adminConfigurationService;
private readonly BlockedDeviceService _blockedDeviceService;
private readonly SessionBlockRecorder _sessionBlockRecorder;
private readonly IUserUsageRepository _userUsageRepo;
private readonly IDistressReportRepository _distressReportRepo;
private readonly IOpsEventRepository _opsEventRepo;
@@ -70,6 +71,7 @@ namespace AutopilotMonitor.Functions.Services
TenantConfigurationService tenantConfigService,
AdminConfigurationService adminConfigurationService,
BlockedDeviceService blockedDeviceService,
SessionBlockRecorder sessionBlockRecorder,
IUserUsageRepository userUsageRepo,
IDistressReportRepository distressReportRepo,
IOpsEventRepository opsEventRepo,
@@ -100,6 +102,7 @@ namespace AutopilotMonitor.Functions.Services
_tenantConfigService = tenantConfigService;
_adminConfigurationService = adminConfigurationService;
_blockedDeviceService = blockedDeviceService;
_sessionBlockRecorder = sessionBlockRecorder;
_userUsageRepo = userUsageRepo;
_distressReportRepo = distressReportRepo;
_opsEventRepo = opsEventRepo;
@@ -473,7 +476,8 @@ namespace AutopilotMonitor.Functions.Services
SessionStatus.Stalled,
VerdictPaths.SweepStalled,
stalledAt: now,
failureReason: $"Agent silent for {silentMinutes}min (detected by maintenance sweep)");
failureReason: DescribeBlockedMonitoring(silent)
?? $"Agent silent for {silentMinutes}min (detected by maintenance sweep)");
silentMarked++;
}
@@ -571,6 +575,12 @@ namespace AutopilotMonitor.Functions.Services
if (targetStatus == session.Status)
continue;
// A session the platform blocked went silent because its uploads were
// refused: the classification stands, the reason names the block.
var blockText = targetStatus == SessionStatus.Succeeded ? null : DescribeBlockedMonitoring(session);
if (blockText != null)
reason = $"{blockText}. {reason}";
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
session.TenantId,
session.SessionId,
@@ -811,6 +821,22 @@ namespace AutopilotMonitor.Functions.Services
return null;
}
/// <summary>
/// The session's block record for an event-limit auto-action. Both actions end with the block;
/// a Kill covers the whole device until then (a new session does not lift it).
/// </summary>
internal static SessionBlockRecord BuildEventLimitBlockRecord(
string action, DateTime blockedAt, int durationHours, int eventCount, int threshold)
=> new()
{
BlockedAt = blockedAt,
Action = action,
BlockedUntil = blockedAt.AddHours(durationHours),
Cause = SessionBlockCauses.EventLimit,
EventCount = eventCount,
EventThreshold = threshold,
};
/// <summary>
/// Pure eligibility gate for the runaway-session auto-action path: only a session that
/// can still upload may cost its device a block. Mirrors the time-window watchdog's
@@ -850,9 +876,10 @@ namespace AutopilotMonitor.Functions.Services
/// Auto-action path (Block/Kill): when <see cref="AdminConfiguration.ExcessiveEventAutoActionMode"/>
/// is set and the session crosses the higher threshold, calls
/// <see cref="BlockedDeviceService.BlockDeviceAsync"/>, emits a Critical
/// <c>ExcessiveSessionEventsAutoActioned</c> ops event, and marks
/// <c>ExcessiveEventsAutoActioned</c>. The two flags are independent so flipping the
/// mode mid-run never re-fires the warn.
/// <c>ExcessiveSessionEventsAutoActioned</c> ops event, and records the block on the
/// session (<see cref="SessionBlockRecorder"/>) — its <c>BlockCause</c> is the auto-action's
/// idempotency marker. Warn and auto-action are tracked apart so flipping the mode
/// mid-run never re-fires the warn.
/// </para>
/// Both paths skip when their threshold is 0; the whole sweep no-ops when both are off.
/// <para>
@@ -907,7 +934,7 @@ namespace AutopilotMonitor.Functions.Services
{
if (!HasPendingExcessiveEventAction(
candidate.EventCount, candidate.Status,
candidate.ExcessiveEventsAlerted, candidate.ExcessiveEventsAutoActioned,
candidate.ExcessiveEventsAlerted, candidate.BlockCause == SessionBlockCauses.EventLimit,
warnEnabled ? warnThreshold : 0, autoEnabled ? autoMode : "Off", autoThreshold))
continue;
@@ -927,7 +954,7 @@ namespace AutopilotMonitor.Functions.Services
}
// Auto-action tier: block/kill once per session when configured.
if (autoEnabled && !session.ExcessiveEventsAutoActioned)
if (autoEnabled && session.BlockCause != SessionBlockCauses.EventLimit)
{
var action = DecideAutoAction(session.EventCount, autoMode, autoThreshold);
if (action == null) continue;
@@ -961,7 +988,8 @@ namespace AutopilotMonitor.Functions.Services
continue;
}
var reason = $"Auto-action: excessive session events ({session.EventCount} events ≥ threshold {autoThreshold})";
var reason = $"Auto-action: excessive session events ({session.EventCount} events, limit {autoThreshold})";
var blockedAt = DateTime.UtcNow;
await _blockedDeviceService.BlockDeviceAsync(
tenantId,
session.SerialNumber,
@@ -974,7 +1002,19 @@ namespace AutopilotMonitor.Functions.Services
await _opsEventService.RecordExcessiveSessionEventsAutoActionedAsync(
tenantId, session.SessionId, session.SerialNumber,
session.EventCount, autoThreshold, action, autoDurationHours);
await _sessionRepo.MarkExcessiveEventsAutoActionedAsync(tenantId, session.SessionId);
// The tenant's only view of the block: card, timeline event and audit entry.
await _sessionBlockRecorder.RecordAsync(tenantId, session.SessionId, session.SerialNumber,
BuildEventLimitBlockRecord(action, blockedAt, autoDurationHours, session.EventCount, autoThreshold));
await _maintenanceRepo.LogAuditEntryAsync(
tenantId, "CREATE", "DeviceBlock", session.SerialNumber, "System.Maintenance",
new Dictionary<string, string>
{
{ "Action", action },
{ "DurationHours", autoDurationHours.ToString() },
{ "Reason", reason },
{ "BlockedSessionId", session.SessionId },
});
totalAutoActioned++;
}
}
@@ -1047,5 +1087,25 @@ namespace AutopilotMonitor.Functions.Services
/// over its argument, pinned by <c>MaintenanceServerFrameLastContactTests</c>.</remarks>
internal static DateTime ServerFrameLastContact(SessionSummary session)
=> session.LastIngestAt ?? session.LastEventAt ?? session.StartedAt;
/// <summary>
/// The status reason of a silent session the platform blocked, or null when its silence has
/// another cause. Refused uploads never stamp the last contact, so a contact after the block's
/// end means the agent came back and went silent for its own reasons.
/// </summary>
internal static string? DescribeBlockedMonitoring(SessionSummary session)
{
if (!session.BlockedAt.HasValue)
return null;
if (session.BlockedUntil.HasValue && ServerFrameLastContact(session) > session.BlockedUntil.Value)
return null;
var by = session.BlockCause == SessionBlockCauses.Operator
? "the platform operator"
: "the platform's event limit";
return session.BlockAction == DeviceBlockActions.Kill
? $"Monitoring ended by {by}"
: $"Monitoring paused by {by}";
}
}
}
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Analyze;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Records a placed device block on the session it stopped: the <c>Block*</c> fields of the
/// session row and a <c>device_blocked</c> timeline event, then an interim analyze run for
/// the rules that evaluate on that event. The tenant sees the block only through this
/// record — the block list itself is platform-only.
/// </summary>
public class SessionBlockRecorder
{
private readonly ISessionRepository _sessionRepo;
private readonly IAnalyzeOnEnrollmentEndProducer _analyzeProducer;
private readonly ILogger<SessionBlockRecorder> _logger;
public SessionBlockRecorder(
ISessionRepository sessionRepo, IAnalyzeOnEnrollmentEndProducer analyzeProducer, ILogger<SessionBlockRecorder> logger)
{
_sessionRepo = sessionRepo;
_analyzeProducer = analyzeProducer;
_logger = logger;
}
/// <summary>
/// Call after the block is in force. Never throws and never undoes the block: a failed
/// record costs the explanation, not the protection.
/// </summary>
public async Task RecordAsync(string tenantId, string sessionId, string serialNumber, SessionBlockRecord record)
{
try
{
var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
if (session == null)
{
_logger.LogWarning(
"Block record skipped: session {SessionId} not found in tenant {TenantId}", sessionId, tenantId);
return;
}
// The session id comes from the caller; it must belong to the blocked device.
if (!string.Equals(session.SerialNumber.Trim(), serialNumber.Trim(), StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Block record skipped: session {SessionId} (tenant {TenantId}) belongs to another device than {SerialNumber}",
sessionId, tenantId, serialNumber);
return;
}
if (!await _sessionRepo.RecordSessionBlockAsync(tenantId, sessionId, record))
return;
var existing = await _sessionRepo.GetSessionEventsAsync(tenantId, sessionId);
var evt = BuildDeviceBlockedEvent(session, record, existing);
var batch = new List<EnrollmentEvent> { evt };
await _sessionRepo.StoreEventsBatchAsync(batch);
// Backend-authored events bypass the ingest processor: without the index upsert the
// event is invisible to every search-by-eventType surface; without the recount the
// stored EventCount misses it.
await _sessionRepo.UpsertEventTypeIndexBatchAsync(tenantId, sessionId, batch);
await _sessionRepo.ReconcileSessionCountersAsync(tenantId, sessionId);
// The event never passes the ingest processor, whose batch scan enqueues the
// on_event interim runs — enqueue the one for device_blocked here.
await _analyzeProducer.EnqueueAsync(new AnalyzeOnEnrollmentEndEnvelope
{
TenantId = tenantId,
SessionId = sessionId,
Reason = AnalyzeOnEnrollmentEndHandler.ReasonInterimTrigger,
TriggerEventTypes = new List<string> { evt.EventType },
EnqueuedAt = DateTime.UtcNow,
});
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to record the device block on session {SessionId} (tenant {TenantId})", sessionId, tenantId);
}
}
/// <summary>
/// Builds the <c>device_blocked</c> event: one past the session's last Sequence and stamped
/// now, so it sorts after everything the agent delivered before the block.
/// </summary>
internal static EnrollmentEvent BuildDeviceBlockedEvent(
SessionSummary session, SessionBlockRecord record, IReadOnlyList<EnrollmentEvent> existingEvents)
{
var maxSequence = existingEvents != null && existingEvents.Count > 0
? existingEvents.Max(e => e.Sequence)
: 0L;
var isEventLimit = record.Cause == SessionBlockCauses.EventLimit;
var data = new Dictionary<string, object>
{
["action"] = record.Action,
["cause"] = record.Cause,
};
if (record.BlockedUntil.HasValue)
data["blockedUntil"] = record.BlockedUntil.Value.ToString("o");
if (isEventLimit && record.EventCount.HasValue)
data["eventCount"] = record.EventCount.Value;
if (isEventLimit && record.EventThreshold.HasValue)
data["threshold"] = record.EventThreshold.Value;
return new EnrollmentEvent
{
TenantId = session.TenantId,
SessionId = session.SessionId,
EventType = AutopilotMonitor.Shared.Constants.EventTypes.DeviceBlocked,
Source = isEventLimit ? "System.Maintenance" : "System.Operator",
Severity = EventSeverity.Warning,
Phase = EnrollmentPhase.Unknown,
Timestamp = record.BlockedAt,
Sequence = maxSequence + 1,
Message = BuildMessage(record),
Data = data,
};
}
internal static string BuildMessage(SessionBlockRecord record)
{
var isKill = record.Action == DeviceBlockActions.Kill;
var hours = record.BlockedUntil.HasValue
? Math.Max(1, (int)Math.Round((record.BlockedUntil.Value - record.BlockedAt).TotalHours))
: 0;
if (record.Cause == SessionBlockCauses.EventLimit && record.EventCount.HasValue && record.EventThreshold.HasValue)
{
var volume = string.Format(CultureInfo.InvariantCulture,
"this session sent {0:N0} events (limit {1:N0})", record.EventCount.Value, record.EventThreshold.Value);
return isKill
? $"Monitoring ended: {volume}; the agent removes itself and the device stays unmonitored for {hours} h."
: $"Monitoring paused: {volume}, so the device's uploads are blocked for {hours} h.";
}
return isKill
? $"Monitoring ended by the platform operator; the agent removes itself and the device stays unmonitored for {hours} h."
: $"Monitoring paused by the platform operator for {hours} h.";
}
}
}
@@ -59,7 +59,6 @@ namespace AutopilotMonitor.Functions.Services
"RemediationScriptCount",
"RebootCount",
"ExcessiveEventsAlerted",
"ExcessiveEventsAutoActioned",
};
/// <summary>Written only when the Sessions row carries a value (absent otherwise).</summary>
@@ -90,6 +89,12 @@ namespace AutopilotMonitor.Functions.Services
"AvgApiLatencyMs",
"ApiRequestCount",
"ConnectionType",
"BlockedAt",
"BlockAction",
"BlockedUntil",
"BlockCause",
"BlockEventCount",
"BlockEventThreshold",
};
/// <summary>Every column an index row may carry (data columns; PK/RK excluded).</summary>
@@ -182,8 +182,7 @@ namespace AutopilotMonitor.Functions.Services
// Search-filterable column (rebootCountMin/Max push an OData filter on the index),
// so it MUST be projected — otherwise a StartedAt-shift full upsert drops it.
["RebootCount"] = sessionEntity.GetInt32("RebootCount") ?? 0,
["ExcessiveEventsAlerted"] = sessionEntity.GetBoolean("ExcessiveEventsAlerted") ?? false,
["ExcessiveEventsAutoActioned"] = sessionEntity.GetBoolean("ExcessiveEventsAutoActioned") ?? false
["ExcessiveEventsAlerted"] = sessionEntity.GetBoolean("ExcessiveEventsAlerted") ?? false
};
// Nullable fields — only written when present.
@@ -296,6 +295,27 @@ namespace AutopilotMonitor.Functions.Services
if (!string.IsNullOrEmpty(connectionType))
indexEntity["ConnectionType"] = connectionType;
// Block record (RecordSessionBlockAsync merges it): the session list renders the
// Blocked pill from it, so a StartedAt-shift full upsert must keep it.
foreach (var col in SessionBlockDateColumns)
{
var at = sessionEntity.GetDateTimeOffset(col)?.UtcDateTime;
if (at.HasValue)
indexEntity[col] = EnsureUtc(at.Value);
}
foreach (var col in SessionBlockStringColumns)
{
var val = sessionEntity.GetString(col);
if (!string.IsNullOrEmpty(val))
indexEntity[col] = val;
}
foreach (var col in SessionBlockCountColumns)
{
var val = sessionEntity.GetInt32(col);
if (val.HasValue)
indexEntity[col] = val.Value;
}
return indexEntity;
}
@@ -3635,6 +3655,12 @@ namespace AutopilotMonitor.Functions.Services
var status = ParseStoredStatus(entity, sessionId);
// Block record: a later record (an operator's Kill over an event-limit block) merges over
// an earlier one and cannot clear the columns it no longer uses — the counts belong to the
// event limit only.
var blockCause = entity.GetString("BlockCause");
var isOperatorBlock = blockCause == SessionBlockCauses.Operator;
return new SessionSummary
{
SessionId = sessionId,
@@ -3697,7 +3723,12 @@ namespace AutopilotMonitor.Functions.Services
RemediationScriptCount = SafeGetInt32(entity, "RemediationScriptCount") ?? 0,
RebootCount = SafeGetInt32(entity, "RebootCount") ?? 0,
ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false,
ExcessiveEventsAutoActioned = entity.GetBoolean("ExcessiveEventsAutoActioned") ?? false,
BlockedAt = SafeGetDateTime(entity, "BlockedAt"),
BlockAction = entity.GetString("BlockAction"),
BlockedUntil = SafeGetDateTime(entity, "BlockedUntil"),
BlockCause = blockCause,
BlockEventCount = isOperatorBlock ? null : SafeGetInt32(entity, "BlockEventCount"),
BlockEventThreshold = isOperatorBlock ? null : SafeGetInt32(entity, "BlockEventThreshold"),
FailureSnapshotJson = entity.GetString("FailureSnapshotJson") ?? string.Empty,
// PR3: cascade-delete state-machine columns. Empty/null on legacy rows is fine —
// SessionDeletionGuard treats null/empty as "None" (no cascade in flight).
@@ -4156,13 +4187,13 @@ namespace AutopilotMonitor.Functions.Services
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{tenantId}' and EventCount gt {threshold}",
// SerialNumber + ExcessiveEventsAutoActioned added for the auto-block/kill
// SerialNumber + BlockCause added for the auto-block/kill
// path in MaintenanceService — lets us decide and execute in one round-trip
// per qualifying session, without a follow-up GetSessionAsync.
// Status is mandatory for that path: SessionStatus.InProgress is ordinal 0, so
// an unselected column would leave every row looking in-progress and the
// auto-action eligibility gate silently always-true.
select: new[] { "PartitionKey", "RowKey", "EventCount", "SerialNumber", "Status", "ExcessiveEventsAlerted", "ExcessiveEventsAutoActioned" });
select: new[] { "PartitionKey", "RowKey", "EventCount", "SerialNumber", "Status", "ExcessiveEventsAlerted", "BlockCause" });
await foreach (var entity in query)
{
@@ -4174,7 +4205,7 @@ namespace AutopilotMonitor.Functions.Services
SerialNumber = entity.GetString("SerialNumber") ?? string.Empty,
Status = ParseStatusForAutoAction(entity.GetString("Status")),
ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false,
ExcessiveEventsAutoActioned = entity.GetBoolean("ExcessiveEventsAutoActioned") ?? false
BlockCause = entity.GetString("BlockCause")
});
}
}
@@ -4222,12 +4253,16 @@ namespace AutopilotMonitor.Functions.Services
}
}
internal static readonly string[] SessionBlockDateColumns = { "BlockedAt", "BlockedUntil" };
internal static readonly string[] SessionBlockStringColumns = { "BlockAction", "BlockCause" };
internal static readonly string[] SessionBlockCountColumns = { "BlockEventCount", "BlockEventThreshold" };
/// <summary>
/// Sets ExcessiveEventsAutoActioned=true on the session entity so the maintenance
/// auto-block/kill emission is idempotent. Best-effort Merge write; failures are
/// logged only (warn-path stays intact even if this fails).
/// Merges the block record into the Sessions row and the SessionsIndex mirror. A later record
/// merges over an earlier one; the columns it leaves out are ignored on read
/// (<c>MapToSessionSummary</c>). Best-effort: failures are logged and reported as false.
/// </summary>
public async Task MarkExcessiveEventsAutoActionedAsync(string tenantId, string sessionId)
public async Task<bool> RecordSessionBlockAsync(string tenantId, string sessionId, SessionBlockRecord record)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
@@ -4237,11 +4272,19 @@ namespace AutopilotMonitor.Functions.Services
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var update = new TableEntity(tenantId, sessionId)
{
["ExcessiveEventsAutoActioned"] = true
["BlockedAt"] = EnsureUtc(record.BlockedAt),
["BlockAction"] = record.Action,
["BlockCause"] = record.Cause,
};
if (record.BlockedUntil.HasValue)
update["BlockedUntil"] = EnsureUtc(record.BlockedUntil.Value);
if (record.EventCount.HasValue)
update["BlockEventCount"] = record.EventCount.Value;
if (record.EventThreshold.HasValue)
update["BlockEventThreshold"] = record.EventThreshold.Value;
await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge);
// Dual-write into SessionsIndex so dashboard queries see the flag without a separate read.
// Dual-write into SessionsIndex so the session list sees the record without a separate read.
try
{
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId,
@@ -4251,12 +4294,14 @@ namespace AutopilotMonitor.Functions.Services
}
catch (Exception ex)
{
_logger.LogDebug(ex, "SessionsIndex merge skipped for ExcessiveEventsAutoActioned on {SessionId}", sessionId);
_logger.LogWarning(ex, "SessionsIndex merge failed for the block record of {SessionId}", sessionId);
}
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark session {SessionId} as ExcessiveEventsAutoActioned", sessionId);
_logger.LogWarning(ex, "Failed to record the block on session {SessionId}", sessionId);
return false;
}
}
@@ -313,6 +313,41 @@ describe('buildSessionCoverage — collectors, upload, disk', () => {
});
});
describe('buildSessionCoverage — platform block', () => {
const base = { ...SESSION, blockedAt: at(3600), blockedUntil: at(3600 + 86400) };
it('reads an event-limit pause from the session record and says the silence after it proves nothing', () => {
const c = buildSessionCoverage(
{ ...base, blockAction: 'Block', blockCause: 'event_limit', blockEventCount: 2553, blockEventThreshold: 2500 },
[ev('agent_started', 5)],
);
expect(c.upload.platformBlock).toEqual({
blockedAt: at(3600),
blockedUntil: at(3600 + 86400),
action: 'Block',
cause: 'event_limit',
eventCount: 2553,
eventThreshold: 2500,
});
expect(c.gaps).toEqual([
`Uploads were paused at ${at(3600)} until ${at(3600 + 86400)} by the platform's event limit (2553 events, limit 2500): no events reached the backend after that point while the pause lasted, so their absence proves nothing.`,
]);
});
it('says a kill removed the agent and keeps the device unmonitored', () => {
const c = buildSessionCoverage({ ...base, blockAction: 'Kill', blockCause: 'operator' }, [ev('agent_started', 5)]);
expect(c.gaps).toEqual([
`Monitoring was ended at ${at(3600)} by the platform operator: the agent removed itself and the device stays unmonitored until ${at(3600 + 86400)}, so nothing after that point was observed.`,
]);
});
it('adds nothing for a session the platform never blocked', () => {
const c = buildSessionCoverage(SESSION, [ev('agent_started', 5)]);
expect(c.upload.platformBlock).toBeUndefined();
expect(c.gaps).toEqual([]);
});
});
describe('buildSessionCoverage — diagnostics package', () => {
it('reports packaging counters and a truncation gap (no paths)', () => {
const c = buildSessionCoverage(SESSION, [
@@ -116,7 +116,7 @@ export interface AdminConfiguration {
desktopDetectorNoCandidateTimeoutMinutes: number;
/** Maintenance alarm threshold: sessions with more events than this value trigger an ExcessiveSessionEvents ops alert (dispatched to Telegram/Teams). 0 = disabled. Default: 2000 (largest real sessions observed are ~500). */
excessiveEventCountThreshold: number;
/** Auto-action mode for runaway sessions whose EventCount exceeds ExcessiveEventAutoActionThreshold. One of "Off", "Block", or "Kill". Off (default) keeps the warn-only behaviour; Block stops the device's uploads for ExcessiveEventAutoActionDurationHours; Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch, is idempotent per session via ExcessiveEventsAutoActioned, and never downgrades an existing Kill to Block. */
/** Auto-action mode for runaway sessions whose EventCount exceeds ExcessiveEventAutoActionThreshold. One of "Off", "Block", or "Kill". Off (default) keeps the warn-only behaviour; Block stops the device's uploads for ExcessiveEventAutoActionDurationHours; Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch, is idempotent per session via its block record (SessionSummary.BlockCause), and never downgrades an existing Kill to Block. */
excessiveEventAutoActionMode: string;
/** Event-count threshold for auto-block/kill. Must be greater than ExcessiveEventCountThreshold so operators see the warn first. 0 disables regardless of ExcessiveEventAutoActionMode. Default: 2500. */
excessiveEventAutoActionThreshold: number;
@@ -3020,7 +3020,12 @@ export interface LocationSessionRow {
remediationScriptCount: number;
rebootCount: number;
excessiveEventsAlerted: boolean;
excessiveEventsAutoActioned: boolean;
blockedAt?: string;
blockAction?: string;
blockedUntil?: string;
blockCause?: string;
blockEventCount?: number;
blockEventThreshold?: number;
pendingActionsJson: string;
pendingActionsQueuedAt?: string;
failureSnapshotJson: string;
@@ -4707,8 +4712,18 @@ export interface SessionSummary {
rebootCount: number;
/** True once maintenance has emitted an ExcessiveSessionEvents ops alert for this session. Prevents duplicate alerts on subsequent maintenance runs for the same runaway session. */
excessiveEventsAlerted: boolean;
/** True once maintenance has auto-blocked or auto-killed the device for this runaway session (see ExcessiveEventAutoActionMode). Independent of ExcessiveEventsAlerted so warn and auto-action are each idempotent on their own — admins can change the auto-action mode mid-flight without re-firing the warn. */
excessiveEventsAutoActioned: boolean;
/** When the platform blocked this session's device (server clock). Null when the session was never blocked. The Block* fields are a record written when the block is placed, not the live block state: the agent stays paused until BlockedUntil even when the block is lifted earlier. */
blockedAt?: string;
/** The block action (DeviceBlockActions): Block pauses uploads, Kill ends monitoring. */
blockAction?: string;
/** When the block ends. A Block covers only this session (a new enrollment lifts it); a Kill covers the whole device until then, new enrollments included. */
blockedUntil?: string;
/** Why the session was blocked (SessionBlockCauses). */
blockCause?: string;
/** The session's event count when the event limit blocked it. Null for other causes. */
blockEventCount?: number;
/** The event limit the session exceeded. Null for other causes. */
blockEventThreshold?: number;
/** JSON-serialized List`1 of ServerAction pending delivery to the agent. Empty string when no actions are queued. The Ingest function reads this alongside the session's status fields (no extra I/O), attaches the actions to the response, and clears the column via a merge. */
pendingActionsJson: string;
/** When the first pending action was queued. Used for TTL and staleness detection — maintenance can purge actions older than a threshold to prevent zombie signals on long-dead sessions. */
@@ -204,6 +204,20 @@ export const RULE_SUBMISSION_DECISIONS = [
] as const;
export type RuleSubmissionDecision = (typeof RULE_SUBMISSION_DECISIONS)[number];
/** What a device block does: Block pauses uploads, Kill ends monitoring (C# DeviceBlockActions). */
export const DEVICE_BLOCK_ACTIONS = [
"Block",
"Kill",
] as const;
export type DeviceBlockAction = (typeof DEVICE_BLOCK_ACTIONS)[number];
/** Why the platform blocked a session (C# SessionBlockCauses). */
export const SESSION_BLOCK_CAUSES = [
"event_limit",
"operator",
] as const;
export type SessionBlockCause = (typeof SESSION_BLOCK_CAUSES)[number];
/** Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources). */
export const LOG_SOURCES = [
"backend",
@@ -234,7 +234,7 @@ export const EVENT_TYPES_CATALOG = {
'admin_marked_session',
],
gather_events: ['gather_result', 'gather_rules_collection_started', 'gather_rules_collection_completed'],
termination_events: ['enrollment_summary_shown', 'reboot_triggered', 'session_timeout'],
termination_events: ['enrollment_summary_shown', 'reboot_triggered', 'session_timeout', 'device_blocked'],
classification_events: ['enrollment_type_mismatch', 'decision_process_completion'],
other: ['error_detected', 'log_entry', 'esp_resumed', 'esp_provisioning_settle_started'],
} as const;
@@ -13,7 +13,8 @@
* in particular before trusting the absence of an event. No backend field, no new event type:
* the block is computed from the same event list the summary already fetches.
*/
import type { EnrollmentEvent } from './generated/wire-types.generated.js';
import type { EnrollmentEvent, SessionSummary } from './generated/wire-types.generated.js';
import type { DeviceBlockAction, SessionBlockCause } from './generated/wire-vocabularies.generated.js';
/** Event types the coverage block reads. Guarded against the catalog by event-types-drift.test.ts. */
export const COVERAGE_EVENT_TYPES: ReadonlySet<string> = new Set([
@@ -159,6 +160,17 @@ export interface UploadCoverage {
intercepted?: UploadInterception;
backpressureEpisodes: number;
diskSpaceLow: boolean;
/** Present when the platform blocked the session's device (event limit or platform operator) — from the session record. */
platformBlock?: PlatformBlock;
}
export interface PlatformBlock {
blockedAt: string;
blockedUntil: string | null;
action: string | null;
cause: string | null;
eventCount: number | null;
eventThreshold: number | null;
}
export type DiagnosticsStatus = 'uploaded' | 'failed' | 'skipped' | 'not_collected';
@@ -262,8 +274,15 @@ function isTerminalSessionStatus(status: unknown): boolean {
* Folds the session's events (already stripped of historic-replay rows by the caller) into the
* coverage block. Pure: no fetch, no clock.
*/
type CoverageSession = { startedAt?: string | null; status?: string | null } & Partial<
Pick<SessionSummary, 'blockedAt' | 'blockedUntil' | 'blockAction' | 'blockCause' | 'blockEventCount' | 'blockEventThreshold'>
>;
const KILL: DeviceBlockAction = 'Kill';
const OPERATOR: SessionBlockCause = 'operator';
export function buildSessionCoverage(
session: { startedAt?: string | null; status?: string | null },
session: CoverageSession,
events: readonly Ev[],
): SessionCoverage {
const byType = new Map<string, Ev[]>();
@@ -465,6 +484,28 @@ export function buildSessionCoverage(
backpressureEpisodes,
diskSpaceLow: !!disk,
};
const blockedAt = str(session.blockedAt);
if (blockedAt) {
const platformBlock: PlatformBlock = {
blockedAt,
blockedUntil: str(session.blockedUntil),
action: str(session.blockAction),
cause: str(session.blockCause),
eventCount: num(session.blockEventCount),
eventThreshold: num(session.blockEventThreshold),
};
upload.platformBlock = platformBlock;
const by =
platformBlock.cause === OPERATOR
? 'the platform operator'
: `the platform's event limit${platformBlock.eventCount !== null && platformBlock.eventThreshold !== null ? ` (${platformBlock.eventCount} events, limit ${platformBlock.eventThreshold})` : ''}`;
const until = platformBlock.blockedUntil ? ` until ${platformBlock.blockedUntil}` : '';
gaps.push(
platformBlock.action === KILL
? `Monitoring was ended at ${blockedAt} by ${by}: the agent removed itself and the device stays unmonitored${until}, so nothing after that point was observed.`
: `Uploads were paused at ${blockedAt}${until} by ${by}: no events reached the backend after that point while the pause lasted, so their absence proves nothing.`,
);
}
if (spool) {
const pending = num(spool.data?.pendingItemCount);
gaps.push(`Telemetry spool pressure${pending !== null ? ` (${pending} items pending)` : ''}: events reached the backend late; anything after the last upload may still be on the device.`);
@@ -997,6 +997,9 @@ namespace AutopilotMonitor.Shared
// SessionTimeoutHours (5h default) window and graduates to terminal Failed. Gives the
// analyze pipeline a terminal event to fire on (parity with the agent's enrollment_failed).
public const string SessionTimeout = "session_timeout";
// Server-authored: written when the platform blocks the session's device (event limit or
// the platform operator). Marks the point after which the session's telemetry was refused.
public const string DeviceBlocked = "device_blocked";
public const string RemoteConfigFetchFailed = "remote_config_fetch_failed";
public const string AgentUnrestrictedModeChanged = "agent_unrestricted_mode_changed";
@@ -208,10 +208,10 @@ namespace AutopilotMonitor.Shared.DataAccess
Task MarkExcessiveEventsAlertedAsync(string tenantId, string sessionId);
/// <summary>
/// Marks the session as already-auto-actioned so maintenance only blocks/kills the
/// device once per runaway session. Independent of <see cref="MarkExcessiveEventsAlertedAsync"/>.
/// Writes the block record onto the session (Sessions + SessionsIndex). A later record replaces
/// an earlier one. Returns false when the write failed; failures are logged, never thrown.
/// </summary>
Task MarkExcessiveEventsAutoActionedAsync(string tenantId, string sessionId);
Task<bool> RecordSessionBlockAsync(string tenantId, string sessionId, SessionBlockRecord record);
/// <summary>
/// Open (non-terminal: Pending / InProgress / Stalled / AwaitingUser) sessions of the same
@@ -0,0 +1,28 @@
using System;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// The block record a session carries once the platform blocked its device — written when the
/// block is placed and surfaced as the <c>Block*</c> fields of <see cref="SessionSummary"/>.
/// </summary>
public sealed class SessionBlockRecord
{
public DateTime BlockedAt { get; set; }
/// <summary><see cref="DeviceBlockActions"/> value.</summary>
public string Action { get; set; } = DeviceBlockActions.Block;
public DateTime? BlockedUntil { get; set; }
/// <summary><see cref="SessionBlockCauses"/> value.</summary>
public string Cause { get; set; } = SessionBlockCauses.EventLimit;
/// <summary>Only for <see cref="SessionBlockCauses.EventLimit"/>.</summary>
public int? EventCount { get; set; }
/// <summary>Only for <see cref="SessionBlockCauses.EventLimit"/>.</summary>
public int? EventThreshold { get; set; }
}
}
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Shared.Models
/// or <c>"Kill"</c>. Off (default) keeps the warn-only behaviour;
/// Block stops the device's uploads for <see cref="ExcessiveEventAutoActionDurationHours"/>;
/// Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch,
/// is idempotent per session via <c>ExcessiveEventsAutoActioned</c>, and never downgrades
/// is idempotent per session via its block record (<c>SessionSummary.BlockCause</c>), and never downgrades
/// an existing Kill to Block.
/// </summary>
public string ExcessiveEventAutoActionMode { get; set; } = "Off";
@@ -555,13 +555,30 @@ namespace AutopilotMonitor.Shared.Models
public bool ExcessiveEventsAlerted { get; set; }
/// <summary>
/// True once maintenance has auto-blocked or auto-killed the device for this runaway
/// session (see <see cref="AutopilotMonitor.Shared.Models.AdminConfiguration.ExcessiveEventAutoActionMode"/>).
/// Independent of <see cref="ExcessiveEventsAlerted"/> so warn and auto-action are
/// each idempotent on their own — admins can change the auto-action mode mid-flight
/// without re-firing the warn.
/// When the platform blocked this session's device (server clock). Null when the session
/// was never blocked. The Block* fields are a record written when the block is placed,
/// not the live block state: the agent stays paused until <see cref="BlockedUntil"/> even
/// when the block is lifted earlier.
/// </summary>
public bool ExcessiveEventsAutoActioned { get; set; }
public DateTime? BlockedAt { get; set; }
/// <summary>The block action (<see cref="DeviceBlockActions"/>): Block pauses uploads, Kill ends monitoring.</summary>
public string? BlockAction { get; set; }
/// <summary>
/// When the block ends. A Block covers only this session (a new enrollment lifts it); a Kill
/// covers the whole device until then, new enrollments included.
/// </summary>
public DateTime? BlockedUntil { get; set; }
/// <summary>Why the session was blocked (<see cref="SessionBlockCauses"/>).</summary>
public string? BlockCause { get; set; }
/// <summary>The session's event count when the event limit blocked it. Null for other causes.</summary>
public int? BlockEventCount { get; set; }
/// <summary>The event limit the session exceeded. Null for other causes.</summary>
public int? BlockEventThreshold { get; set; }
/// <summary>
/// JSON-serialized <see cref="System.Collections.Generic.List{T}"/> of <see cref="ServerAction"/>
@@ -0,0 +1,29 @@
namespace AutopilotMonitor.Shared.Models
{
/// <summary>What a device block does once the agent receives it.</summary>
public static class DeviceBlockActions
{
/// <summary>The session's uploads pause until the block ends; the agent keeps running.</summary>
public const string Block = "Block";
/// <summary>
/// The agent ends monitoring and removes itself; until the block ends, every agent on the
/// device does the same, new enrollments included.
/// </summary>
public const string Kill = "Kill";
public static readonly string[] All = { Block, Kill };
}
/// <summary>Why the platform blocked a session (<see cref="SessionSummary.BlockCause"/>).</summary>
public static class SessionBlockCauses
{
/// <summary>The session exceeded the platform's event limit (maintenance auto-action).</summary>
public const string EventLimit = "event_limit";
/// <summary>The platform operator blocked the device for this session.</summary>
public const string Operator = "operator";
public static readonly string[] All = { EventLimit, Operator };
}
}
@@ -16,6 +16,7 @@ import {
parseSessionSearchQuery,
} from "../lib/sessionSearchQuery";
import { SessionStatusBadge } from "@/components/SessionStatusBadge";
import { BLOCKED_ICON_PATH, sessionBlockOf, sessionBlockTooltip } from "@/lib/sessionBlock";
import { TenantFilterBar } from "./TenantFilterBar";
import type { DeleteTarget } from "../hooks/useDeleteSession";
import type { BlockTarget } from "../hooks/useBlockDevice";
@@ -1072,6 +1073,8 @@ function SessionCell({
onBlockDevices: (targets: BlockTarget[]) => void;
tenantDomainById: Map<string, string>;
}) {
// The session's own block record reaches every role; the GA live list adds whole-device blocks.
const sessionBlock = sessionBlockOf(session);
switch (columnKey) {
case "device":
return (
@@ -1159,13 +1162,13 @@ function SessionCell({
Cloud PC
</span>
)}
{blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) && (
{(sessionBlock || blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`)) && (
<span
className="px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-orange-100 text-orange-800"
title="Device is currently blocked"
title={sessionBlock ? sessionBlockTooltip(sessionBlock) : "Device is currently blocked"}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={BLOCKED_ICON_PATH} />
</svg>
Blocked
</span>
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/lib/config";
const LAST_UPDATED = "13 October 2026";
const LAST_UPDATED = "14 October 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
@@ -215,7 +215,7 @@ export default function PrivacyPage() {
<li><strong>Structural tenant isolation</strong> — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header</li>
<li><strong>Encryption</strong> — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys</li>
<li><strong>Managed identity instead of storage keys</strong>, and secret-less OIDC deployment pipelines</li>
<li><strong>Rate limiting</strong> per device, per portal user, and per MCP user</li>
<li><strong>Rate limiting</strong> per device, per portal user, and per MCP user, and an event limit per enrollment session that pauses a device&apos;s uploads when it sends far more than an enrollment needs</li>
<li><strong>Verifiable agent binaries</strong> — Authenticode-signed scripts and binaries, third-party libraries only with their vendor&apos;s own signature, a catalog signed by glueckkanja AG that lists every file of the agent package and is checked on the device before anything runs, Sigstore build attestation, and SHA-256 verification at download, through an independent backend cross-check, and at runtime</li>
<li><strong>Manifest-first deletion</strong> — what will be deleted is captured, restorably, before anything is removed</li>
</ul>
@@ -37,8 +37,11 @@ interface FailureSnapshot {
export default function FailureSnapshotBlock({
failureSnapshotJson,
afterBlock = false,
}: {
failureSnapshotJson?: string;
/** The platform blocked the session: its silence is the block, which the card above explains. */
afterBlock?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
@@ -73,7 +76,16 @@ export default function FailureSnapshotBlock({
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
Failure Snapshot {snapshot.silenceMinutes !== undefined && <span className="font-normal">(agent went silent {snapshot.silenceMinutes} min before timeout)</span>}
{afterBlock ? (
<>
Failure Snapshot{" "}
<span className="font-normal">(last known state before monitoring stopped)</span>
</>
) : (
<>
Failure Snapshot {snapshot.silenceMinutes !== undefined && <span className="font-normal">(agent went silent {snapshot.silenceMinutes} min before timeout)</span>}
</>
)}
</span>
<svg className={`w-4 h-4 transition-transform ${expanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -0,0 +1,60 @@
import { DocsLink } from "@/components/DocsLink";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { BLOCKED_ICON_PATH, formatBlockTime, type SessionBlock } from "@/lib/sessionBlock";
/**
* Says why a session stopped reporting when the platform blocked its device: the event limit
* or the platform operator paused its uploads (orange, the Blocked pill's family) or ended
* monitoring (red). Rendered only for sessions that carry a block record.
*/
export function MonitoringBlockedBlock({ block }: { block: SessionBlock | null }) {
if (!block) return null;
const at = formatBlockTime(block.blockedAt);
const until = block.blockedUntil ? formatBlockTime(block.blockedUntil) : at;
const count = block.eventCount;
const limit = block.eventThreshold;
const title = `${block.isKill ? "Monitoring ended" : "Monitoring paused"}${
block.byOperator ? " by the platform operator" : " — event limit reached"
}`;
const action = block.isKill
? `the agent stopped monitoring on ${at} and removed itself.`
: `uploads from this session are paused from ${at} until ${until}.`;
const scope = block.isKill
? `Until ${until}, new enrollments on this device are not monitored either.`
: "The enrollment itself is not affected, and the device's next enrollment starts unblocked.";
const tone = block.isKill
? "bg-red-50 border-red-200 text-red-900 dark:bg-red-900/30 dark:border-red-800 dark:text-red-200"
: "bg-orange-50 border-orange-200 text-orange-900 dark:bg-orange-900/30 dark:border-orange-800 dark:text-orange-200";
return (
<div className={`mt-4 flex items-start gap-2 px-3 py-2 rounded-lg border text-sm ${tone}`}>
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={BLOCKED_ICON_PATH} />
</svg>
<div className="min-w-0">
<strong>{title}</strong>
<p className="mt-1">
{block.byOperator ? (
action.charAt(0).toUpperCase() + action.slice(1)
) : (
<>
{count !== undefined && limit !== undefined && (
<>
This session sent {count.toLocaleString("en-US")} events, above the limit of{" "}
{limit.toLocaleString("en-US")}.{" "}
</>
)}
To protect your fleet data and the platform, {action}
</>
)}
</p>
<p className="mt-1">
{scope}{" "}
<DocsLink path={DOCS_PATHS.protectiveLimits} label="Learn more" />
</p>
</div>
</div>
);
}
@@ -4,8 +4,10 @@ import { useState, type ReactNode } from "react";
import { Session } from "@/types";
import { SessionStatusBadge } from "@/components/SessionStatusBadge";
import FailureSnapshotBlock from "./FailureSnapshotBlock";
import { MonitoringBlockedBlock } from "./MonitoringBlockedBlock";
import { deviceClockDirection } from "./sessionInfoLogic";
import { formatDuration } from "@/lib/formatting";
import { sessionBlockOf } from "@/lib/sessionBlock";
interface SessionInfoCardProps {
session: Session;
@@ -21,6 +23,7 @@ interface SessionInfoCardProps {
}
export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession, ntpOffset, configMgrDetected, standbySeconds, footerHint }: SessionInfoCardProps) {
const block = sessionBlockOf(session);
const lastContactTooltip = session.lastEventAt
? `Last contact: ${new Date(session.lastEventAt).toLocaleString([], { dateStyle: "short", timeStyle: "medium" })}`
: undefined;
@@ -148,7 +151,8 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
</div>
)}
{footerHint}
<FailureSnapshotBlock failureSnapshotJson={session.failureSnapshotJson} />
<MonitoringBlockedBlock block={block} />
<FailureSnapshotBlock failureSnapshotJson={session.failureSnapshotJson} afterBlock={block !== null} />
</div>
);
}
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MonitoringBlockedBlock } from "../MonitoringBlockedBlock";
import FailureSnapshotBlock from "../FailureSnapshotBlock";
import { sessionBlockOf, sessionBlockTooltip } from "@/lib/sessionBlock";
import { makeSession } from "@/test/factories";
const BLOCKED_AT = "2026-10-14T12:00:00Z";
const BLOCKED_UNTIL = "2026-10-15T12:00:00Z";
const eventLimit = (blockAction: string) =>
makeSession({
blockedAt: BLOCKED_AT,
blockedUntil: BLOCKED_UNTIL,
blockAction,
blockCause: "event_limit",
blockEventCount: 2553,
blockEventThreshold: 2500,
});
const byOperator = (blockAction: string) =>
makeSession({ blockedAt: BLOCKED_AT, blockedUntil: BLOCKED_UNTIL, blockAction, blockCause: "operator" });
const render = (session = makeSession()) =>
renderToStaticMarkup(createElement(MonitoringBlockedBlock, { block: sessionBlockOf(session) }));
describe("MonitoringBlockedBlock", () => {
it("renders nothing for a session the platform never blocked", () => {
expect(render()).toBe("");
});
it("explains an event-limit pause with the session's numbers and its window", () => {
const html = render(eventLimit("Block"));
expect(html).toContain("Monitoring paused — event limit reached");
expect(html).toContain("This session sent 2,553 events, above the limit of 2,500.");
expect(html).toContain("uploads from this session are paused from");
expect(html).toContain("the device&#x27;s next enrollment starts unblocked");
expect(html).toContain("bg-orange-50");
expect(html).not.toContain("bg-red-50");
});
it("says a kill also keeps the device's next enrollments unmonitored", () => {
const html = render(eventLimit("Kill"));
expect(html).toContain("Monitoring ended — event limit reached");
expect(html).toContain("the agent stopped monitoring on");
expect(html).toContain("new enrollments on this device are not monitored either");
expect(html).not.toContain("starts unblocked");
expect(html).toContain("bg-red-50");
});
it("names the operator and no volume for an operator block", () => {
const html = render(byOperator("Block"));
expect(html).toContain("Monitoring paused by the platform operator");
expect(html).toContain("Uploads from this session are paused from");
expect(html).not.toContain("events, above the limit");
expect(html).not.toContain("To protect your fleet data");
});
it("links to the protective-limits article", () => {
expect(render(eventLimit("Block"))).toContain("/concepts/protective-limits");
});
});
describe("sessionBlock", () => {
it("maps the record and words the session list's pill line", () => {
const block = sessionBlockOf(eventLimit("Kill"))!;
expect(block.isKill).toBe(true);
expect(block.byOperator).toBe(false);
expect(sessionBlockTooltip(block)).toMatch(/^Monitoring ended by the event limit on /);
expect(sessionBlockTooltip(sessionBlockOf(byOperator("Block"))!)).toMatch(/^Monitoring paused by the platform operator on /);
});
});
describe("FailureSnapshotBlock after a block", () => {
const snapshot = JSON.stringify({ silenceMinutes: 300, eventCount: 2553 });
it("names the pause instead of a silent agent", () => {
const html = renderToStaticMarkup(createElement(FailureSnapshotBlock, { failureSnapshotJson: snapshot, afterBlock: true }));
expect(html).toContain("last known state before monitoring stopped");
expect(html).not.toContain("agent went silent");
});
it("keeps the silence title for every other timeout", () => {
const html = renderToStaticMarkup(createElement(FailureSnapshotBlock, { failureSnapshotJson: snapshot }));
expect(html).toContain("agent went silent");
});
});
@@ -43,6 +43,8 @@ export const DOCS_PATHS = {
geographicPerformance: "/portal-guide/geographic-performance",
geographicPerformanceNumbers: "/portal-guide/geographic-performance#how-the-numbers-are-calculated",
statistics: "/concepts/averages-medians-and-percentiles",
// Session page block card (event limit / platform operator)
protectiveLimits: "/concepts/protective-limits",
slaCompliance: "/portal-guide/sla-compliance",
softwareInventory: "/portal-guide/software-inventory-and-vulnerabilities",
usageMetrics: "/portal-guide/usage-metrics",
@@ -505,6 +505,14 @@ export const SHARED_MANIFEST = {
"ime",
"realmjoin"
],
"deviceBlockActions": [
"Block",
"Kill"
],
"sessionBlockCauses": [
"event_limit",
"operator"
],
"sessionAnchorIneligibleSources": [
"SystemTimelineWatcher",
"WindowsUpdateWatcher",
@@ -1039,6 +1047,7 @@ export const SHARED_MANIFEST = {
"desktop_detector_started",
"desktop_excluded_user",
"desktop_real_user_detected",
"device_blocked",
"device_location",
"device_registration_event",
"diagnostics_collecting",
@@ -501,6 +501,14 @@
"ime",
"realmjoin"
],
"deviceBlockActions": [
"Block",
"Kill"
],
"sessionBlockCauses": [
"event_limit",
"operator"
],
"sessionAnchorIneligibleSources": [
"SystemTimelineWatcher",
"WindowsUpdateWatcher",
@@ -1035,6 +1043,7 @@
"desktop_detector_started",
"desktop_excluded_user",
"desktop_real_user_detected",
"device_blocked",
"device_location",
"device_registration_event",
"diagnostics_collecting",
@@ -1615,7 +1624,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "Auto-action mode for runaway sessions whose EventCount exceeds ExcessiveEventAutoActionThreshold. One of \"Off\", \"Block\", or \"Kill\". Off (default) keeps the warn-only behaviour; Block stops the device's uploads for ExcessiveEventAutoActionDurationHours; Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch, is idempotent per session via ExcessiveEventsAutoActioned, and never downgrades an existing Kill to Block."
"doc": "Auto-action mode for runaway sessions whose EventCount exceeds ExcessiveEventAutoActionThreshold. One of \"Off\", \"Block\", or \"Kill\". Off (default) keeps the warn-only behaviour; Block stops the device's uploads for ExcessiveEventAutoActionDurationHours; Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch, is idempotent per session via its block record (SessionSummary.BlockCause), and never downgrades an existing Kill to Block."
},
{
"name": "excessiveEventAutoActionThreshold",
@@ -14334,11 +14343,52 @@
}
},
{
"name": "excessiveEventsAutoActioned",
"name": "blockedAt",
"type": {
"kind": "primitive",
"name": "boolean"
}
"name": "string"
},
"optional": true
},
{
"name": "blockAction",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
},
{
"name": "blockedUntil",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
},
{
"name": "blockCause",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
},
{
"name": "blockEventCount",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true
},
{
"name": "blockEventThreshold",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true
},
{
"name": "pendingActionsJson",
@@ -21762,12 +21812,58 @@
"doc": "True once maintenance has emitted an ExcessiveSessionEvents ops alert for this session. Prevents duplicate alerts on subsequent maintenance runs for the same runaway session."
},
{
"name": "excessiveEventsAutoActioned",
"name": "blockedAt",
"type": {
"kind": "primitive",
"name": "boolean"
"name": "string"
},
"doc": "True once maintenance has auto-blocked or auto-killed the device for this runaway session (see ExcessiveEventAutoActionMode). Independent of ExcessiveEventsAlerted so warn and auto-action are each idempotent on their own — admins can change the auto-action mode mid-flight without re-firing the warn."
"optional": true,
"doc": "When the platform blocked this session's device (server clock). Null when the session was never blocked. The Block* fields are a record written when the block is placed, not the live block state: the agent stays paused until BlockedUntil even when the block is lifted earlier."
},
{
"name": "blockAction",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "The block action (DeviceBlockActions): Block pauses uploads, Kill ends monitoring."
},
{
"name": "blockedUntil",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "When the block ends. A Block covers only this session (a new enrollment lifts it); a Kill covers the whole device until then, new enrollments included."
},
{
"name": "blockCause",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Why the session was blocked (SessionBlockCauses)."
},
{
"name": "blockEventCount",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "The session's event count when the event limit blocked it. Null for other causes."
},
{
"name": "blockEventThreshold",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "The event limit the session exceeded. Null for other causes."
},
{
"name": "pendingActionsJson",
@@ -116,7 +116,7 @@ export interface AdminConfiguration {
desktopDetectorNoCandidateTimeoutMinutes: number;
/** Maintenance alarm threshold: sessions with more events than this value trigger an ExcessiveSessionEvents ops alert (dispatched to Telegram/Teams). 0 = disabled. Default: 2000 (largest real sessions observed are ~500). */
excessiveEventCountThreshold: number;
/** Auto-action mode for runaway sessions whose EventCount exceeds ExcessiveEventAutoActionThreshold. One of "Off", "Block", or "Kill". Off (default) keeps the warn-only behaviour; Block stops the device's uploads for ExcessiveEventAutoActionDurationHours; Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch, is idempotent per session via ExcessiveEventsAutoActioned, and never downgrades an existing Kill to Block. */
/** Auto-action mode for runaway sessions whose EventCount exceeds ExcessiveEventAutoActionThreshold. One of "Off", "Block", or "Kill". Off (default) keeps the warn-only behaviour; Block stops the device's uploads for ExcessiveEventAutoActionDurationHours; Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch, is idempotent per session via its block record (SessionSummary.BlockCause), and never downgrades an existing Kill to Block. */
excessiveEventAutoActionMode: string;
/** Event-count threshold for auto-block/kill. Must be greater than ExcessiveEventCountThreshold so operators see the warn first. 0 disables regardless of ExcessiveEventAutoActionMode. Default: 2500. */
excessiveEventAutoActionThreshold: number;
@@ -3020,7 +3020,12 @@ export interface LocationSessionRow {
remediationScriptCount: number;
rebootCount: number;
excessiveEventsAlerted: boolean;
excessiveEventsAutoActioned: boolean;
blockedAt?: string;
blockAction?: string;
blockedUntil?: string;
blockCause?: string;
blockEventCount?: number;
blockEventThreshold?: number;
pendingActionsJson: string;
pendingActionsQueuedAt?: string;
failureSnapshotJson: string;
@@ -4707,8 +4712,18 @@ export interface SessionSummary {
rebootCount: number;
/** True once maintenance has emitted an ExcessiveSessionEvents ops alert for this session. Prevents duplicate alerts on subsequent maintenance runs for the same runaway session. */
excessiveEventsAlerted: boolean;
/** True once maintenance has auto-blocked or auto-killed the device for this runaway session (see ExcessiveEventAutoActionMode). Independent of ExcessiveEventsAlerted so warn and auto-action are each idempotent on their own — admins can change the auto-action mode mid-flight without re-firing the warn. */
excessiveEventsAutoActioned: boolean;
/** When the platform blocked this session's device (server clock). Null when the session was never blocked. The Block* fields are a record written when the block is placed, not the live block state: the agent stays paused until BlockedUntil even when the block is lifted earlier. */
blockedAt?: string;
/** The block action (DeviceBlockActions): Block pauses uploads, Kill ends monitoring. */
blockAction?: string;
/** When the block ends. A Block covers only this session (a new enrollment lifts it); a Kill covers the whole device until then, new enrollments included. */
blockedUntil?: string;
/** Why the session was blocked (SessionBlockCauses). */
blockCause?: string;
/** The session's event count when the event limit blocked it. Null for other causes. */
blockEventCount?: number;
/** The event limit the session exceeded. Null for other causes. */
blockEventThreshold?: number;
/** JSON-serialized List`1 of ServerAction pending delivery to the agent. Empty string when no actions are queued. The Ingest function reads this alongside the session's status fields (no extra I/O), attaches the actions to the response, and clears the column via a merge. */
pendingActionsJson: string;
/** When the first pending action was queued. Used for TTL and staleness detection — maintenance can purge actions older than a threshold to prevent zombie signals on long-dead sessions. */
@@ -0,0 +1,56 @@
import { SHARED_MANIFEST } from "@/lib/generated/shared-manifests.generated";
import type { Session } from "@/types";
/** What a device block does (backend `DeviceBlockActions`). */
export type DeviceBlockAction = (typeof SHARED_MANIFEST.deviceBlockActions)[number];
/** Why the platform blocked a session (backend `SessionBlockCauses`). */
export type SessionBlockCause = (typeof SHARED_MANIFEST.sessionBlockCauses)[number];
const KILL: DeviceBlockAction = "Kill";
const OPERATOR: SessionBlockCause = "operator";
/** The ban icon of the Blocked pill and the session page's block card. */
export const BLOCKED_ICON_PATH =
"M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636";
/** A session's block record — written when the platform blocked its device, never the live block list. */
export interface SessionBlock {
blockedAt: string;
blockedUntil?: string;
/** The agent ended monitoring and removed itself; the device stays unmonitored until `blockedUntil`. */
isKill: boolean;
/** Placed by the platform operator rather than the event limit. */
byOperator: boolean;
eventCount?: number;
eventThreshold?: number;
}
type BlockFields = Pick<
Session,
"blockedAt" | "blockAction" | "blockedUntil" | "blockCause" | "blockEventCount" | "blockEventThreshold"
>;
/** The session's block record, or null when the platform never blocked it. */
export function sessionBlockOf(session: BlockFields): SessionBlock | null {
if (!session.blockedAt) return null;
return {
blockedAt: session.blockedAt,
blockedUntil: session.blockedUntil,
isKill: session.blockAction === KILL,
byOperator: session.blockCause === OPERATOR,
eventCount: session.blockEventCount,
eventThreshold: session.blockEventThreshold,
};
}
/** Local short date and time, the format of the session page's other timestamps. */
export function formatBlockTime(iso: string): string {
return new Date(iso).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
}
/** One line for the session list's Blocked pill. */
export function sessionBlockTooltip(block: SessionBlock): string {
const verb = block.isKill ? "Monitoring ended" : "Monitoring paused";
const by = block.byOperator ? "by the platform operator" : "by the event limit";
return `${verb} ${by} on ${formatBlockTime(block.blockedAt)}`;
}
@@ -102,6 +102,10 @@ const MCP_VOCABULARIES = [
doc: "How a contributed rule credits its author (C# RuleAttributionModes)." },
{ section: "ruleSubmissionDecisions", constName: "RULE_SUBMISSION_DECISIONS", typeName: "RuleSubmissionDecision",
doc: "Reviewer decisions on a rule submission (C# RuleSubmissionDecisions)." },
{ section: "deviceBlockActions", constName: "DEVICE_BLOCK_ACTIONS", typeName: "DeviceBlockAction",
doc: "What a device block does: Block pauses uploads, Kill ends monitoring (C# DeviceBlockActions)." },
{ section: "sessionBlockCauses", constName: "SESSION_BLOCK_CAUSES", typeName: "SessionBlockCause",
doc: "Why the platform blocked a session (C# SessionBlockCauses)." },
{ section: "logSources", constName: "LOG_SOURCES", typeName: "LogSourceName",
doc: "Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources)." },
{ section: "apiErrorCodes", constName: "API_ERROR_CODES", typeName: "ApiErrorCode",
@@ -55,7 +55,6 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
remediationScriptCount: 0,
rebootCount: 0,
excessiveEventsAlerted: false,
excessiveEventsAutoActioned: false,
pendingActionsJson: "",
failureSnapshotJson: "",
deletionState: "",