mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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();
|
||||
|
||||
-4
@@ -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"),
|
||||
|
||||
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user