fix(maintenance): remove the time-window auto-block, it never measured data volume

GetExcessiveDataSendersAsync gated on LastEventAt - StartedAt alone. No event
count, no bytes, no rate — the name asserted something the code never checked.
What it actually selected was every enrollment session that spanned a night,
which for Autopilot is the ordinary case: device unboxed in the evening, ESP
runs, machine powered off, user signs in the next morning.

The per-device alerts from the previous commit made this visible. All three
devices blocked at the time of the analysis were false positives:

  CND3320949 / 64ed21e8    23 events over 15.5h  (1.5 events/hour)
  5CG4303CPT / db5751bc   192 events over 16.8h  (blocked mid Account Setup 1/5)
  PF5YANFB   / 2069de87   781 events over 31.5h

14 such blocks in 30 days across 9 customer tenants. Over the same window the
event-count detector fired 5 times and was right every time (2742, 2714, 2712,
2506 events). Each false block costs a live enrollment 8 hours of telemetry.

Repairing it rather than removing it does not pay: once a volume condition is
added, "many events AND long span" is a subset of "many events", which is what
the count detector already does. Volume is the quantity that costs anything; a
23-event session spanning 15 hours costs nothing. So the detector, its two admin
settings, the ExcessiveDataBlocked ops event and the portal section go, and the
event-count path is the only automatic block left.

MaxSessionWindowHours and MaintenanceBlockDurationHours are deleted rather than
deprecated: the config row is written with Replace, so the columns drop out on
the next portal save, and nothing reads them meanwhile. A comment on the
surviving detector records why there is no second one, so it does not get
rebuilt a year from now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-22 15:28:28 +02:00
parent 3b2f5ed46d
commit 71d2c0e47f
14 changed files with 33 additions and 330 deletions
@@ -117,7 +117,6 @@ public class OffboardingSessionEnumeratorTests
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetSessionsLeanAsync(string tenantId) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours) => throw new NotImplementedException();
public Task<List<string>> GetAllTenantIdsAsync() => throw new NotImplementedException();
public Task<int> DeleteSessionEventsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
public Task<int> DeleteSessionRuleResultsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
@@ -590,8 +590,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "ExcessiveEventAutoActionMode", config.ExcessiveEventAutoActionMode ?? "Off" },
{ "ExcessiveEventAutoActionThreshold", config.ExcessiveEventAutoActionThreshold },
{ "ExcessiveEventAutoActionDurationHours", config.ExcessiveEventAutoActionDurationHours },
{ "MaxSessionWindowHours", config.MaxSessionWindowHours },
{ "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours },
{ "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson },
{ "ModernDeploymentHarmlessEventIdsJson", config.ModernDeploymentHarmlessEventIdsJson ?? string.Empty },
{ "WhiteGloveSealingPatternIdsJson", config.WhiteGloveSealingPatternIdsJson ?? string.Empty },
@@ -657,8 +655,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
ExcessiveEventAutoActionMode = entity.GetString("ExcessiveEventAutoActionMode") ?? "Off",
ExcessiveEventAutoActionThreshold = entity.GetInt32("ExcessiveEventAutoActionThreshold") ?? 2500,
ExcessiveEventAutoActionDurationHours = entity.GetInt32("ExcessiveEventAutoActionDurationHours") ?? 24,
MaxSessionWindowHours = entity.GetInt32("MaxSessionWindowHours") ?? 24,
MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12,
DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"),
ModernDeploymentHarmlessEventIdsJson = entity.GetString("ModernDeploymentHarmlessEventIdsJson"),
WhiteGloveSealingPatternIdsJson = entity.GetString("WhiteGloveSealingPatternIdsJson"),
@@ -69,9 +69,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff)
=> _storage.GetAgentSilentSessionsAsync(tenantId, silenceCutoff, hardCutoff);
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours)
=> _storage.GetExcessiveDataSendersAsync(tenantId, windowCutoff, maxSessionWindowHours);
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults)
=> _storage.GetLegacyTimeoutFailedSessionsAsync(tenantId, maxResults);
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -134,7 +134,6 @@ namespace AutopilotMonitor.Functions.Services
{
await MarkStalledSessionsAsTimedOutAsync();
await DetectExcessiveEventSessionsAsync();
await BlockExcessiveDataSendersAsync();
await AggregateMetricsWithCatchUpAsync();
// Plan §5 PR6 / §16 R14: session retention fanout extracted out of the 2h timer
// into the dedicated 12h SessionDeletionMaintenanceFunction so cascade-lifecycle
@@ -590,6 +589,14 @@ namespace AutopilotMonitor.Functions.Services
/// 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>
/// This is the ONLY automatic device block. A second, time-window detector used to run
/// beside it (AdminConfiguration.MaxSessionWindowHours) and blocked any session whose
/// first and last event lay more than N hours apart. It measured span, never volume, so
/// it flagged every enrollment that spanned a night — 23-event sessions included — and
/// was removed 2026-07-22. Event count is the quantity that actually costs anything;
/// if a span-based guard is ever reintroduced it has to gate on volume too.
/// </para>
/// </summary>
private async Task DetectExcessiveEventSessionsAsync()
{
@@ -711,114 +718,6 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Detects devices that are still actively sending data beyond the configured maximum session
/// window and blocks them automatically. Status-independent: uses LastEventAt (written on every
/// event batch) combined with StartedAt to identify sessions spanning too long a data window.
/// Controlled by AdminConfiguration.MaxSessionWindowHours (0 = disabled).
/// </summary>
private async Task<int> BlockExcessiveDataSendersAsync()
{
_logger.LogInformation("Checking for excessive data senders...");
var sw = Stopwatch.StartNew();
try
{
var adminConfig = await _adminConfigurationService.GetConfigurationAsync();
if (adminConfig.MaxSessionWindowHours == 0)
{
_logger.LogInformation("Excessive data sender detection disabled (MaxSessionWindowHours = 0)");
return 0;
}
var windowCutoff = DateTime.UtcNow.AddHours(-adminConfig.MaxSessionWindowHours);
var blockDurationHours = adminConfig.MaintenanceBlockDurationHours;
var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync();
int totalBlocked = 0;
foreach (var tenantId in tenantIds)
{
try
{
var sessions = await _maintenanceRepo.GetExcessiveDataSendersAsync(tenantId, windowCutoff, adminConfig.MaxSessionWindowHours);
if (sessions.Count == 0)
{
_logger.LogInformation($"Tenant {tenantId}: No excessive data sender sessions found (window: {adminConfig.MaxSessionWindowHours}h)");
continue;
}
int blockedCount = 0;
foreach (var session in sessions)
{
if (string.IsNullOrEmpty(session.SerialNumber))
continue;
var (isBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
if (isBlocked)
continue;
var reason = $"Excessive data window: session active for >{adminConfig.MaxSessionWindowHours}h " +
$"(started {session.StartedAt:yyyy-MM-dd HH:mm:ss} UTC, " +
$"last event {session.LastEventAt:yyyy-MM-dd HH:mm:ss} UTC)";
await _blockedDeviceService.BlockDeviceAsync(
tenantId,
session.SerialNumber,
durationHours: blockDurationHours,
blockedByEmail: "System.Maintenance",
reason: reason,
blockedSessionId: session.SessionId);
// One ops event per device, not per tenant: the alert is only actionable
// if it names the session and serial behind it (the detail modal's
// View session / Block / Kill shortcuts key off `sessionId`).
await _opsEventService.RecordExcessiveDataBlockedAsync(
tenantId, session.SerialNumber, session.SessionId,
adminConfig.MaxSessionWindowHours, blockDurationHours);
blockedCount++;
}
if (blockedCount > 0)
{
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
"ExcessiveDataBlock",
"Device",
$"{blockedCount} devices",
"System.Maintenance",
new Dictionary<string, string>
{
{ "DevicesBlocked", blockedCount.ToString() },
{ "WindowHours", adminConfig.MaxSessionWindowHours.ToString() },
{ "BlockDurationHours", blockDurationHours.ToString() }
});
_logger.LogInformation($"Tenant {tenantId}: Blocked {blockedCount} excessive data sender device(s) (window: {adminConfig.MaxSessionWindowHours}h, block: {blockDurationHours}h)");
}
totalBlocked += blockedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to check excessive data senders for tenant {tenantId}");
}
}
sw.Stop();
_logger.LogInformation($"Excessive data sender check completed: {totalBlocked} devices blocked in {sw.ElapsedMilliseconds}ms");
return totalBlocked;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check for excessive data senders");
return 0;
}
}
/// <summary>
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
@@ -255,19 +255,9 @@ namespace AutopilotMonitor.Functions.Services
$"Device {serialNumber} blocked: {reason}",
tenantId, blockedBy, new { serialNumber, reason });
/// <summary>
/// Fired once per device that the time-window watchdog auto-blocks, i.e. a session still
/// uploading beyond <c>AdminConfiguration.MaxSessionWindowHours</c>. Per device rather than
/// per tenant so the Ops Events detail modal can deep-link View session / Block / Kill
/// against the device behind the alert — same payload contract as
/// <see cref="RecordExcessiveSessionEventsAutoActionedAsync"/>. The aggregate count stays
/// in the maintenance audit entry, where a sum is the useful view.
/// </summary>
public Task RecordExcessiveDataBlockedAsync(
string tenantId, string serialNumber, string sessionId, int windowHours, int durationHours)
=> WriteAsync(OpsEventCategory.Security, "ExcessiveDataBlocked", OpsEventSeverity.Warning,
$"Device {serialNumber} auto-blocked for excessive data (session active >{windowHours}h, {durationHours}h block)",
tenantId, "System.Maintenance", new { sessionId, serialNumber, windowHours, durationHours });
// ExcessiveDataBlocked was removed 2026-07-22 together with the time-window detector that
// raised it — it blocked on session span alone and fired on ordinary overnight enrollments.
// Automatic blocks now come only from the event-count path below.
public Task RecordVersionBlockedAsync(string pattern, string blockedBy)
=> WriteAsync(OpsEventCategory.Security, "VersionBlocked", OpsEventSeverity.Warning,
@@ -1,4 +1,4 @@
using Azure;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
@@ -345,8 +345,8 @@ namespace AutopilotMonitor.Functions.Services
}
}
// Suppress automated maintenance entries (SessionTimeout, ExcessiveDataBlock,
// DataRetentionCleanup, …) from the human-facing audit list. They are written
// Suppress automated maintenance entries (SessionTimeout, DataRetentionCleanup, …)
// from the human-facing audit list. They are written
// by MaintenanceService for traceability but duplicate the OpsEvents stream
// and are not actionable as user-attributable audits. Rows stay in storage
// and remain reachable via raw table queries.
@@ -792,73 +792,6 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Gets sessions where the device has been actively sending data for longer than
/// <paramref name="maxSessionWindowHours"/>.
/// Status-independent: detects excessive data senders regardless of session status.
/// Uses LastEventAt (written on every event batch) for the "still active" check.
/// Sessions without LastEventAt (predating this field) are not returned.
///
/// The OData pre-filter narrows candidates to sessions that straddle the cutoff boundary,
/// then a post-filter verifies the actual session duration (LastEventAt − StartedAt)
/// exceeds the allowed window. This prevents false positives from short sessions that
/// merely happen to straddle the cutoff time.
/// </summary>
public async Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// OData pre-filter: narrow to sessions that straddle the cutoff boundary.
// Status eq 'InProgress' → only block devices whose session is still actively
// sending data. Completed sessions (Succeeded/Failed) must NOT be re-blocked
// even if their LastEventAt is still within the window — they cannot continue
// to abuse data transfer. This also defends against ghost-blocks caused by
// devices with bad clocks: an agent that submits events with timestamps from
// weeks in the past pushes StartedAt back, making the session look long-lived;
// but once the session is Succeeded/Failed, no further data flows from it.
// IsPreProvisioned ne true → exclude WhiteGlove sessions: a pre-provisioned device
// that resumes after weeks in storage looks like an excessive sender (StartedAt old,
// LastEventAt recent) but is a legitimate resumption, not abuse.
var cutoffStr = windowCutoff.ToString("yyyy-MM-ddTHH:mm:ss");
var filter = $"PartitionKey eq '{tenantId}' " +
$"and Status eq 'InProgress' " +
$"and LastEventAt gt datetime'{cutoffStr}Z' " +
$"and StartedAt lt datetime'{cutoffStr}Z' " +
$"and IsPreProvisioned ne true";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var maxDuration = TimeSpan.FromHours(maxSessionWindowHours);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
// Post-filter: verify actual session duration exceeds the window.
// OData cannot compute date differences, so we check in code.
var startedAt = entity.GetDateTimeOffset("StartedAt")?.UtcDateTime;
var lastEventAt = entity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
if (startedAt.HasValue && lastEventAt.HasValue
&& (lastEventAt.Value - startedAt.Value) < maxDuration)
{
continue;
}
sessions.Add(MapToSessionSummary(entity));
}
return sessions;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get excessive data sender sessions for tenant {tenantId}");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets all known tenant IDs from the TenantConfiguration table (one "config" row per
/// tenant). This replaces an O(corpus) scan of the entire Sessions table — the maintenance
@@ -82,7 +82,6 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null);
Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime);
Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff);
Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours);
// --- Legacy Reclassification (misclassification audit 2026-07-16) ---
/// <summary>
@@ -111,27 +111,15 @@ namespace AutopilotMonitor.Shared.Models
public int ExcessiveEventAutoActionThreshold { get; set; } = 2500;
/// <summary>
/// Duration in hours for the device block created by the auto-action path. Independent
/// of <see cref="MaintenanceBlockDurationHours"/> (which governs the time-window
/// ExcessiveDataBlocked path) so the operator can tune both autonomously. Default: 24.
/// Duration in hours for the device block created by the auto-action path. Default: 24.
/// </summary>
public int ExcessiveEventAutoActionDurationHours { get; set; } = 24;
// ===== MAINTENANCE AUTO-BLOCK SETTINGS =====
/// <summary>
/// Max active data window in hours for maintenance auto-block detection.
/// Sessions with LastEventAt within the last MaxSessionWindowHours AND StartedAt older
/// than MaxSessionWindowHours will have their device blocked by the nightly maintenance function.
/// 0 = disabled. Default: 24.
/// </summary>
public int MaxSessionWindowHours { get; set; } = 24;
/// <summary>
/// Duration in hours for maintenance-triggered device blocks (excessive data senders).
/// Default: 12.
/// </summary>
public int MaintenanceBlockDurationHours { get; set; } = 12;
// MaxSessionWindowHours / MaintenanceBlockDurationHours were removed 2026-07-22 with the
// time-window auto-block. That detector blocked on session span alone, so every enrollment
// that spanned a night qualified — including 23-event sessions. The event-count settings
// above are now the only automatic block. Stored rows keep the two columns until their
// next write; nothing reads them.
/// <summary>
/// Retention period in days for operational events in the OpsEvents table.
@@ -531,9 +519,7 @@ namespace AutopilotMonitor.Shared.Models
GlobalAdminRateLimitRequestsPerMinute = 600,
PlatformStatsBlobSasUrl = string.Empty,
CollectorIdleTimeoutMinutes = 15,
DesktopDetectorNoCandidateTimeoutMinutes = 10,
MaxSessionWindowHours = 24,
MaintenanceBlockDurationHours = 12
DesktopDetectorNoCandidateTimeoutMinutes = 10
};
}
}
@@ -34,10 +34,6 @@ interface AdminConfigContextValue {
setCollectorIdleTimeoutMinutes: (value: number) => void;
desktopDetectorNoCandidateTimeoutMinutes: number;
setDesktopDetectorNoCandidateTimeoutMinutes: (value: number) => void;
maxSessionWindowHours: number;
setMaxSessionWindowHours: (value: number) => void;
maintenanceBlockDurationHours: number;
setMaintenanceBlockDurationHours: (value: number) => void;
opsEventRetentionDays: number;
setOpsEventRetentionDays: (value: number) => void;
slaNotificationCooldownHours: number;
@@ -138,8 +134,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson] = useState("");
const [collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes] = useState(15);
const [desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes] = useState(10);
const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24);
const [maintenanceBlockDurationHours, setMaintenanceBlockDurationHours] = useState(12);
const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90);
const [slaNotificationCooldownHours, setSlaNotificationCooldownHours] = useState(24);
const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false);
@@ -201,8 +195,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setAgentMigrateTenantOverridesJson(data.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(data.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(data.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24);
setMaintenanceBlockDurationHours(data.maintenanceBlockDurationHours ?? 12);
setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90);
setSlaNotificationCooldownHours(data.slaNotificationCooldownHours ?? 24);
setAllowAgentDowngrade(data.allowAgentDowngrade ?? false);
@@ -308,8 +300,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
agentMigrateTenantOverridesJson: agentMigrateTenantOverridesJson.trim(),
collectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes,
maxSessionWindowHours,
maintenanceBlockDurationHours,
opsEventRetentionDays,
slaNotificationCooldownHours,
allowAgentDowngrade,
@@ -342,7 +332,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -355,8 +345,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setAgentMigrateTenantOverridesJson(adminConfig.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(adminConfig.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24);
setMaintenanceBlockDurationHours(adminConfig.maintenanceBlockDurationHours ?? 12);
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
setSlaNotificationCooldownHours(adminConfig.slaNotificationCooldownHours ?? 24);
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
@@ -501,8 +489,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson,
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
maxSessionWindowHours, setMaxSessionWindowHours,
maintenanceBlockDurationHours, setMaintenanceBlockDurationHours,
opsEventRetentionDays, setOpsEventRetentionDays,
slaNotificationCooldownHours, setSlaNotificationCooldownHours,
allowAgentDowngrade, setAllowAgentDowngrade,
@@ -39,13 +39,6 @@ interface BlockedDevice {
interface DeviceBlockSectionProps {
tenants: TenantConfiguration[];
maxSessionWindowHours: number;
setMaxSessionWindowHours: (value: number) => void;
maintenanceBlockDurationHours: number;
setMaintenanceBlockDurationHours: (value: number) => void;
savingConfig: boolean;
adminConfigExists: boolean;
onSaveAdminConfig: () => Promise<void>;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
@@ -53,13 +46,6 @@ interface DeviceBlockSectionProps {
export function DeviceBlockSection({
tenants,
maxSessionWindowHours,
setMaxSessionWindowHours,
maintenanceBlockDurationHours,
setMaintenanceBlockDurationHours,
savingConfig,
adminConfigExists,
onSaveAdminConfig,
getAccessToken,
setError,
setSuccessMessage,
@@ -320,60 +306,6 @@ export function DeviceBlockSection({
</div>
</div>
<div className="p-6 space-y-6">
{/* Maintenance Auto-Block Settings */}
<div className="border border-red-200 dark:border-red-700 rounded-lg p-4 bg-red-50/50 dark:bg-red-900/10">
<div className="flex items-center space-x-2 mb-3">
<svg className="w-4 h-4 text-red-500 dark:text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100">Maintenance Auto-Block Settings</h3>
</div>
<p className="text-xs text-red-600 dark:text-red-400 mb-4">
The nightly maintenance function automatically blocks devices that are still actively sending data beyond the configured window. Sessions with a <code className="bg-red-100 dark:bg-red-900/40 px-1 rounded">LastEventAt</code> timestamp within the window <em>and</em> a <code className="bg-red-100 dark:bg-red-900/40 px-1 rounded">StartedAt</code> older than the window are flagged – regardless of session status.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Session Window (Hours)
</label>
<input
type="number"
min={0}
max={168}
value={maxSessionWindowHours}
onChange={(e) => setMaxSessionWindowHours(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = disabled</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Maintenance Block Duration (Hours)
</label>
<input
type="number"
min={1}
max={720}
value={maintenanceBlockDurationHours}
onChange={(e) => setMaintenanceBlockDurationHours(parseInt(e.target.value) || 12)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Applied by the maintenance function</p>
</div>
</div>
<button
onClick={onSaveAdminConfig}
disabled={!canMutate || savingConfig || !adminConfigExists}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 flex items-center space-x-2"
>
{savingConfig ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Saving...</span></>
) : (
<span>Save Maintenance Settings</span>
)}
</button>
</div>
{/* Block / Kill a device form */}
<div>
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100 mb-3">Block or Kill a Device</h3>
@@ -54,7 +54,7 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
],
Security: [
"DeviceBlocked",
"ExcessiveDataBlocked",
// ExcessiveDataBlocked removed 2026-07-22 with the time-window auto-block that raised it.
// Critical-tier auto-action: emitted when maintenance auto-blocks/kills a device
// after its session crosses `excessiveEventAutoActionThreshold`. Dual-register
// per memory feedback_ops_event_types_dual_register so operators can wire a
@@ -5,26 +5,13 @@ import { DeviceBlockSection } from "../../components/DeviceBlockSection";
import { AdminNotifications } from "../../AdminNotifications";
export function SectionDeviceBlock() {
const {
tenants,
maxSessionWindowHours, setMaxSessionWindowHours,
maintenanceBlockDurationHours, setMaintenanceBlockDurationHours,
savingConfig, adminConfig,
handleSaveAdminConfig, getAccessToken, setError, setSuccessMessage,
} = useAdminConfig();
const { tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
return (
<>
<AdminNotifications />
<DeviceBlockSection
tenants={tenants}
maxSessionWindowHours={maxSessionWindowHours}
setMaxSessionWindowHours={setMaxSessionWindowHours}
maintenanceBlockDurationHours={maintenanceBlockDurationHours}
setMaintenanceBlockDurationHours={setMaintenanceBlockDurationHours}
savingConfig={savingConfig}
adminConfigExists={!!adminConfig}
onSaveAdminConfig={handleSaveAdminConfig}
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
@@ -47,21 +47,22 @@ describe("blockedSessionCount", () => {
});
});
describe("ExcessiveDataBlocked payload reaches the detail modal's actions", () => {
describe("auto-block payload reaches the detail modal's actions", () => {
it("carries a sessionId the modal can deep-link on", () => {
// Mirrors OpsEventService.RecordExcessiveDataBlockedAsync. This event used to be
// aggregated per tenant ({devicesBlocked, windowHours}), which left the modal with
// no device to act on — exactly the alert where an operator wants to act.
// Mirrors OpsEventService.RecordExcessiveSessionEventsAutoActionedAsync — the only
// automatic block left after the time-window detector was removed.
const details = JSON.stringify({
sessionId: "806f61c3-1978-4e5c-8fd7-a571cb0fe6bc",
serialNumber: "PF5YANFB",
windowHours: 12,
durationHours: 8,
eventCount: 2742,
threshold: 2500,
action: "Block",
durationHours: 24,
});
expect(extractSessionId(details)).toBe("806f61c3-1978-4e5c-8fd7-a571cb0fe6bc");
});
it("would have rendered no actions for the old aggregate payload", () => {
it("renders no actions for a payload without a session", () => {
expect(extractSessionId(JSON.stringify({ devicesBlocked: 1, windowHours: 12 }))).toBeNull();
});
});
@@ -35,8 +35,6 @@ export interface AdminConfiguration {
excessiveEventAutoActionThreshold?: number;
/** Block duration in hours when the auto-action fires. */
excessiveEventAutoActionDurationHours?: number;
maxSessionWindowHours?: number;
maintenanceBlockDurationHours?: number;
opsEventRetentionDays?: number;
slaNotificationCooldownHours?: number;
diagnosticsGlobalLogPathsJson?: string;