diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConnectionTypeFilterTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConnectionTypeFilterTests.cs new file mode 100644 index 00000000..395c71d6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConnectionTypeFilterTests.cs @@ -0,0 +1,48 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Cover for the ConnectionType predicate used on every search path that does NOT push the +/// filter to OData — the deviceProperties batch-get (ApplyBasicFilters) and the legacy +/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match +/// a set filter; an unset filter matches everything. +/// +public class ConnectionTypeFilterTests +{ + private static SessionSummary Session(string? connectionType) => + new() { SessionId = "s", TenantId = "t", ConnectionType = connectionType }; + + [Theory] + [InlineData(null)] + [InlineData("WiFi")] + [InlineData("Ethernet")] + public void NoFilter_AlwaysMatches(string? connectionType) + { + var filter = new SessionSearchFilter(); + Assert.True(TableStorageService.MatchesConnectionType(Session(connectionType), filter)); + } + + [Theory] + [InlineData("Ethernet", true)] + [InlineData("ethernet", true)] // case-insensitive, matching the other string filters + [InlineData("WiFi", false)] + [InlineData(null, false)] // legacy session without the projected column + public void EthernetFilter_MatchesOnlyEthernet(string? connectionType, bool expected) + { + var filter = new SessionSearchFilter { ConnectionType = "Ethernet" }; + Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter)); + } + + [Theory] + [InlineData("WiFi", true)] + [InlineData("Ethernet", false)] + [InlineData(null, false)] + public void WiFiFilter_MatchesOnlyWiFi(string? connectionType, bool expected) + { + var filter = new SessionSearchFilter { ConnectionType = "WiFi" }; + Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IngestConnectionTypeProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IngestConnectionTypeProjectionTests.cs new file mode 100644 index 00000000..0edf4cdb --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IngestConnectionTypeProjectionTests.cs @@ -0,0 +1,124 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the per-session connection-type projection (2026-07-28): the agent stamps +/// connectionType ("WiFi"/"Ethernet") into every network_interface_info +/// emission (initial collect + re-collect on change); ingest takes the LAST matching +/// event of the batch and persists it via UpdateSessionConnectionTypeAsync +/// (last-write-wins → a device that switches media mid-enrollment is relabeled). +/// The no-NIC payload ({"status":"no_active_interface"}) carries no connectionType +/// and must never match. +/// +public class IngestConnectionTypeProjectionTests +{ + private static EnrollmentEvent NicInfo(string? connectionType) + { + var evt = new EnrollmentEvent + { + EventType = "network_interface_info", + Data = new Dictionary + { + ["adapterName"] = "Ethernet 2", + ["linkSpeedMbps"] = 1000L, + }, + }; + if (connectionType != null) evt.Data["connectionType"] = connectionType; + return evt; + } + + [Fact] + public void EthernetEmission_IsExtracted() + { + var events = new List { NicInfo("Ethernet") }; + + Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type)); + Assert.Equal("Ethernet", type); + } + + [Fact] + public void LastEmissionOfBatch_Wins() + { + // Media switch mid-batch (dock removed → WiFi) — the most recent state wins. + var events = new List + { + NicInfo("Ethernet"), + new EnrollmentEvent { EventType = "download_progress" }, + NicInfo("WiFi"), + }; + + Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type)); + Assert.Equal("WiFi", type); + } + + [Fact] + public void NoActiveInterfacePayload_DoesNotMatch() + { + // The no-NIC emission carries only {"status":"no_active_interface"}. + var events = new List + { + new EnrollmentEvent + { + EventType = "network_interface_info", + Data = new Dictionary { ["status"] = "no_active_interface" }, + }, + }; + + Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _)); + } + + [Fact] + public void NoActiveInterfaceAfterValidEmission_DoesNotShadowIt() + { + // The predicate selects the last event that CARRIES connectionType. + var events = new List + { + NicInfo("WiFi"), + new EnrollmentEvent + { + EventType = "network_interface_info", + Data = new Dictionary { ["status"] = "no_active_interface" }, + }, + }; + + Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type)); + Assert.Equal("WiFi", type); + } + + [Theory] + [InlineData("Cellular")] + [InlineData("wifi")] // agent emits exact casing — anything else is unexpected + [InlineData("")] + public void UnexpectedValues_AreDroppedDefensively(string raw) + { + var events = new List { NicInfo(raw) }; + + Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _)); + } + + [Fact] + public void BatchWithoutNicEvents_DoesNotMatch() + { + var events = new List + { + new EnrollmentEvent { EventType = "esp_phase_changed" }, + new EnrollmentEvent { EventType = "agent_metrics_snapshot" }, + }; + + Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _)); + } + + [Fact] + public void NullData_DoesNotMatch() + { + var events = new List + { + new EnrollmentEvent { EventType = "network_interface_info", Data = null! }, + }; + + Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs index eccff1f9..1aaaf854 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs @@ -45,6 +45,12 @@ public class SessionIndexProjectionTests ["IsSelfDeployingProfile"] = true, // Device-validation path recorded at registration — shown on the session detail page. ["ValidatedBy"] = "AutopilotV1", + // Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these). + ["AvgApiLatencyMs"] = 513.5, + ["ApiRequestCount"] = 100, + // Connection-type projection (UpdateSessionConnectionTypeAsync merges this) — + // search-filterable (connectionType= pushes an OData eq on the index). + ["ConnectionType"] = "Ethernet", }; var idx = TableStorageService.BuildSessionIndexEntity(session, IndexRowKey, StartedAt); @@ -67,6 +73,9 @@ public class SessionIndexProjectionTests Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned")); Assert.True(idx.GetBoolean("IsSelfDeployingProfile")); Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy")); + Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs")); + Assert.Equal(100, idx.GetInt32("ApiRequestCount")); + Assert.Equal("Ethernet", idx.GetString("ConnectionType")); } [Fact] @@ -86,6 +95,8 @@ public class SessionIndexProjectionTests Assert.False(idx.ContainsKey("FailureSource")); Assert.False(idx.ContainsKey("StalledAt")); Assert.False(idx.ContainsKey("ValidatedBy")); + Assert.False(idx.ContainsKey("AvgApiLatencyMs")); + Assert.False(idx.ContainsKey("ConnectionType")); // Always-present counts/flags mirror the Sessions defaults. Assert.Equal(0, idx.GetInt32("PlatformScriptCount")); Assert.Equal(0, idx.GetInt32("RemediationScriptCount")); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs index b615c583..a38b0864 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs @@ -40,6 +40,7 @@ public class StoreSessionReregistrationPreserveTests ["PlatformScriptCount"] = 3, ["RemediationScriptCount"] = 5, ["RebootCount"] = 7, + ["ConnectionType"] = "Ethernet", }; existing.ETag = new ETag("0xEXISTING"); @@ -71,6 +72,9 @@ public class StoreSessionReregistrationPreserveTests Assert.Equal(7, harness.Written.GetInt32("RebootCount")); // EventCount preservation is the established sibling behaviour — assert it as a sanity anchor. Assert.Equal(42, harness.Written.GetInt32("EventCount")); + // Merge-mode field (UpdateSessionConnectionTypeAsync) — must survive the Replace so the + // value isn't lost in the window until the restarted agent re-emits network_interface_info. + Assert.Equal("Ethernet", harness.Written.GetString("ConnectionType")); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index e45a0f8e..659d90da 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -152,6 +152,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage double avgLatencyMs, int requestCount) => _storage.UpdateSessionNetworkLatencyAsync(tenantId, sessionId, avgLatencyMs, requestCount); + public Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId, + string connectionType) + => _storage.UpdateSessionConnectionTypeAsync(tenantId, sessionId, connectionType); + public Task> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold) => _storage.GetSessionsWithEventCountAboveAsync(tenantId, threshold); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs index de37a58a..23e62d85 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs @@ -130,6 +130,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw filter.IsSelfDeployingProfile = sdp; if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin; if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax; + var connectionType = query["connectionType"]; + if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType; var callerTenantId = TenantHelper.GetTenantId(req); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs index 51fee81d..686509af 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs @@ -140,6 +140,7 @@ public class SearchSessionsFunction ImeAgentVersion = query["imeAgentVersion"], AgentVersionPrefix = query["agentVersionPrefix"], ImeAgentVersionPrefix = query["imeAgentVersionPrefix"], + ConnectionType = query["connectionType"], // Limit field is a no-op in the paged path — pagination drives count. }; @@ -212,6 +213,7 @@ public class SearchSessionsFunction if (fields.Contains("rebootCount")) dict["rebootCount"] = s.RebootCount; if (fields.Contains("avgApiLatencyMs")) dict["avgApiLatencyMs"] = s.AvgApiLatencyMs; if (fields.Contains("apiRequestCount")) dict["apiRequestCount"] = s.ApiRequestCount; + if (fields.Contains("connectionType")) dict["connectionType"] = s.ConnectionType; if (dict.Count == 0) { diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs index 7070649a..38426202 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs @@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Pagination new KeyValuePair("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), new KeyValuePair("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)), new KeyValuePair("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)), + new KeyValuePair("connectionType", filter.ConnectionType), }; if (filter.DeviceProperties != null) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs index 96ec945c..3be6db21 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs @@ -151,6 +151,14 @@ namespace AutopilotMonitor.Functions.Services "Fire-and-forget UpdateSessionNetworkLatencyAsync failed"), TaskContinuationOptions.OnlyOnFaulted); } + if (TryExtractConnectionType(request.Events, out var connectionType)) + { + _ = _sessionRepo.UpdateSessionConnectionTypeAsync( + request.TenantId, request.SessionId, connectionType) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget UpdateSessionConnectionTypeAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + } + var classification = ClassifyEvents(storedEvents); foreach (var summary in classification.AppInstallUpdates.Values) @@ -432,6 +440,28 @@ namespace AutopilotMonitor.Functions.Services return true; } + /// + /// Connection-type projection: the agent stamps connectionType ("WiFi"/"Ethernet") + /// into every network_interface_info emission (initial collect + re-collect), so the + /// LAST event of the batch carries the current media and a plain overwrite is + /// idempotent against replays. The no-NIC payload ({"status":"no_active_interface"}) + /// carries no connectionType and never matches. Values outside WiFi/Ethernet are + /// dropped defensively. Exposed as internal for unit testing. + /// + internal static bool TryExtractConnectionType( + List events, out string connectionType) + { + connectionType = string.Empty; + var nicEvent = events.LastOrDefault(e => + e.EventType == Shared.Constants.EventTypes.NetworkInterfaceInfo && + e.Data?.ContainsKey("connectionType") == true); + var raw = nicEvent?.Data?["connectionType"]?.ToString(); + if (raw != "WiFi" && raw != "Ethernet") + return false; + connectionType = raw; + return true; + } + /// /// Numeric reader for agent event Data values, which arrive as boxed Newtonsoft /// primitives (integer → long, decimal → double) — same coercion ladder as diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index c771be48..436ce2d5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -654,6 +654,11 @@ namespace AutopilotMonitor.Functions.Services parts.Add($"RebootCount ge {filter.RebootCountMin.Value}"); if (filter.RebootCountMax.HasValue) parts.Add($"RebootCount le {filter.RebootCountMax.Value}"); + // ConnectionType exact match pushed to OData. Legacy index rows that predate + // the projected column lack the property and are excluded by the eq (they + // carry no connection data, which is the intended result). + if (!string.IsNullOrEmpty(filter.ConnectionType)) + parts.Add($"ConnectionType eq '{ODataSanitizer.EscapeValue(filter.ConnectionType)}'"); return parts.Count == 0 ? null : string.Join(" and ", parts); } @@ -670,6 +675,19 @@ namespace AutopilotMonitor.Functions.Services return true; } + /// + /// Client-side ConnectionType predicate — same backstop role as + /// : applied on the search paths that do NOT + /// push the filter to OData (device-snapshot batch-get, legacy unpaged scan) and + /// redundantly on the OData scan paths. Sessions without the projected column + /// (null) never match a set filter. + /// + internal static bool MatchesConnectionType(SessionSummary session, SessionSearchFilter filter) + { + if (string.IsNullOrEmpty(filter.ConnectionType)) return true; + return string.Equals(session.ConnectionType, filter.ConnectionType, StringComparison.OrdinalIgnoreCase); + } + private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter) { if (!string.IsNullOrEmpty(filter.SerialNumber) && @@ -683,9 +701,10 @@ namespace AutopilotMonitor.Functions.Services return false; if (filter.StartedAfter.HasValue && session.StartedAt < filter.StartedAfter.Value) return false; if (filter.StartedBefore.HasValue && session.StartedAt > filter.StartedBefore.Value) return false; - // RebootCount is also pushed to OData in BuildSearchScanFilter; this is a defensive - // backstop so the bound holds even if that push-down is ever weakened. + // RebootCount / ConnectionType are also pushed to OData in BuildSearchScanFilter; + // these are defensive backstops so the bound holds even if that push-down is ever weakened. if (!MatchesRebootCountBounds(session, filter)) return false; + if (!MatchesConnectionType(session, filter)) return false; // AgentVersion / ImeAgentVersion (exact + prefix) are pushed to OData // in BuildSearchScanFilter — the server has already filtered them out // before we see the page. No client-side check needed. @@ -951,9 +970,11 @@ namespace AutopilotMonitor.Functions.Services && (s.ImeAgentVersion == null || !s.ImeAgentVersion.StartsWith(filter.ImeAgentVersionPrefix!, StringComparison.OrdinalIgnoreCase))) return false; - // Device-snapshot path has no RebootCount OData push-down — enforce it here so a - // deviceProperties + rebootCountMin query can't return sub-threshold sessions. + // Device-snapshot path has no RebootCount/ConnectionType OData push-down — + // enforce them here so a deviceProperties + rebootCountMin/connectionType + // query can't return non-matching sessions. if (!MatchesRebootCountBounds(s, filter)) return false; + if (!MatchesConnectionType(s, filter)) return false; return true; }).ToList(); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 8ccf9964..74aa09fd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -225,6 +225,12 @@ namespace AutopilotMonitor.Functions.Services if (apiRequestCount.HasValue) indexEntity["ApiRequestCount"] = apiRequestCount.Value; + // Connection-type projection (UpdateSessionConnectionTypeAsync merges this) — + // must be in this superset or a StartedAt-shift full upsert drops it. + var connectionType = sessionEntity.GetString("ConnectionType"); + if (!string.IsNullOrEmpty(connectionType)) + indexEntity["ConnectionType"] = connectionType; + return indexEntity; } @@ -398,6 +404,7 @@ namespace AutopilotMonitor.Functions.Services string geoRegion = string.Empty; string geoCity = string.Empty; string geoLoc = string.Empty; + string? connectionType = null; string? existingIndexRowKey = null; // PR3: cascade-delete state-machine columns. Re-registration MUST preserve // these — losing them would silently clear the lock and let the agent's writes @@ -444,6 +451,7 @@ namespace AutopilotMonitor.Functions.Services geoRegion = existingEntity.GetString("GeoRegion") ?? string.Empty; geoCity = existingEntity.GetString("GeoCity") ?? string.Empty; geoLoc = existingEntity.GetString("GeoLoc") ?? string.Empty; + connectionType = existingEntity.GetString("ConnectionType"); existingIndexRowKey = existingEntity.GetString("IndexRowKey"); existingDeletionState = existingEntity.GetString("DeletionState"); existingPendingDeletionManifestId = existingEntity.GetString("PendingDeletionManifestId"); @@ -553,6 +561,12 @@ namespace AutopilotMonitor.Functions.Services if (!string.IsNullOrEmpty(geoLoc)) entity["GeoLoc"] = geoLoc; + // Merge-mode field (UpdateSessionConnectionTypeAsync) — preserve through the + // Replace; the agent re-emits network_interface_info after restart, but the + // value must not be lost in the window until it does. + if (!string.IsNullOrEmpty(connectionType)) + entity["ConnectionType"] = connectionType; + // Preserve IndexRowKey through the Replace so that: // 1. Concurrent UpdateSessionStatusAsync calls can find it (prevents index stale-status) // 2. UpsertSessionIndexAsync can detect and delete old index entries when startedAt shifts @@ -2714,6 +2728,52 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Stores the active network connection type ("WiFi"/"Ethernet") from the latest + /// network_interface_info event (Merge-mode, single field, last-write-wins). + /// Same UpdateEntity-not-Upsert rationale as : + /// a tombstoned Sessions row must stay tombstoned (404 → no-op). + /// Non-fatal: failures are logged as warnings and do not block ingest. + /// + public async Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId, + string connectionType) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var update = new TableEntity(tenantId, sessionId) + { + ["ConnectionType"] = connectionType + }; + await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge); + + // Mirror to SessionsIndex — serves the session list and the cheap + // connectionType= search filter (OData eq push-down on the index). + var idxRef = await tableClient.GetEntityAsync( + tenantId, sessionId, select: new[] { "IndexRowKey" }); + var indexRowKey = idxRef.Value.GetString("IndexRowKey"); + if (!string.IsNullOrEmpty(indexRowKey)) + { + await MergeSessionIndexAsync(tenantId, indexRowKey, + new TableEntity(tenantId, indexRowKey) + { + ["ConnectionType"] = connectionType + }); + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogDebug( + "UpdateSessionConnectionType no-op: Sessions row {Tenant}/{Session} is absent (tombstoned or never registered)", + tenantId, sessionId); + } + catch (Exception ex) + { + // Non-fatal — don't block ingest + _logger.LogWarning(ex, "Failed to update connection type for session {SessionId}", sessionId); + } + } + // ===== SESSION/EVENT MAPPING HELPERS ===== /// @@ -2809,6 +2869,7 @@ namespace AutopilotMonitor.Functions.Services DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt), AvgApiLatencyMs = SafeGetDouble(entity, "AvgApiLatencyMs"), ApiRequestCount = SafeGetInt32(entity, "ApiRequestCount"), + ConnectionType = entity.GetString("ConnectionType"), EnrollmentType = entity.GetString("EnrollmentType") ?? "v1", DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"), // Legacy rows that predate this field surface as null → download path treats diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index dc97647f..4f50080b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -894,6 +894,8 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo isPreProvisioned: z.boolean().optional().describe('Filter pre-provisioned (white-glove) sessions'), isHybridJoin: z.boolean().optional().describe('Filter hybrid AAD-join sessions'), isSelfDeployingProfile: z.boolean().optional().describe('Filter self-deploying/kiosk profile sessions (CloudAssignedOobeConfig 0x20|0x40)'), + connectionType: z.enum(['WiFi', 'Ethernet']).optional() + .describe('Active network connection type ("WiFi" or "Ethernet", exact match on the indexed column; last emission wins). Sessions predating the projection lack the column and are excluded.'), fields: z.string().optional().describe('Comma-separated pass-through projection over the literal stored column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); narrows the row but never drops a real column. PartitionKey + RowKey are always kept. Omit for the full raw row.'), pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), @@ -906,14 +908,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo try { const { tenantId: rawTenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild, - geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args; + geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize, continuation } = args; const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation); const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions', tenantId); const path = followNextLink( basePath, { tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild, - geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize }, + geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize }, continuation, ); const data = await apiFetch(path); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index f1a710f8..2e57ed0c 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -139,6 +139,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: 'e.g. rebootCountMin=5. Only populated for v2 enrollments; sessions that predate the field are excluded.'), rebootCountMax: z.coerce.number().int().min(0).optional() .describe('Maximum number of reboots observed during enrollment (<=).'), + connectionType: z.enum(['WiFi', 'Ethernet']).optional() + .describe('Active network connection type during enrollment ("WiFi" or "Ethernet"), indexed for cheap exact-match ' + + 'filtering (e.g. "how many machines enrolled over WiFi?"). Last emission wins — a device that switches media ' + + 'mid-enrollment reports the most recent state. Sessions that predate the projection lack the column and are excluded.'), fields: z.string().optional() .describe('Comma-separated lean projection (e.g. "sessionId,status,agentVersion,startedAt"). ' + 'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' + @@ -146,7 +150,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: 'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' + 'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' + 'avgApiLatencyMs, apiRequestCount (agent→backend HTTP round-trip; weight latency by apiRequestCount when aggregating, ' + - 'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field).'), + 'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field), ' + + 'connectionType ("WiFi"/"Ethernet"; null on sessions predating the projection).'), deviceProperties: z.record(z.string(), z.string()).optional().describe( 'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' + 'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' + diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 70a2e23d..b027c693 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -115,6 +115,13 @@ namespace AutopilotMonitor.Shared.DataAccess /// Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId, double avgLatencyMs, int requestCount); + /// + /// Persists the active network connection type ("WiFi" or "Ethernet") from the + /// latest network_interface_info event. Last-write-wins; fail-soft like + /// the other supplementary-field updaters. + /// + Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId, + string connectionType); // --- Server→Agent Actions --- /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 7d88714f..871f568b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -275,6 +275,14 @@ namespace AutopilotMonitor.Shared.Models /// public int? ApiRequestCount { get; set; } + /// + /// Active network connection type during enrollment: "WiFi" or "Ethernet". + /// Projected at ingest from the latest network_interface_info event + /// (last emission wins — a device that switches media mid-enrollment reports + /// the most recent state). Null for sessions that predate the projection. + /// + public string? ConnectionType { get; set; } + /// /// Enrollment type: "v1" (Autopilot Classic/ESP) or "v2" (Windows Device Preparation). /// Defaults to "v1" for sessions that predate this field. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs index 88e14023..ad2ef528 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs @@ -37,6 +37,11 @@ namespace AutopilotMonitor.Shared.Models public int? RebootCountMin { get; set; } public int? RebootCountMax { get; set; } + // Active network connection type ("WiFi" or "Ethernet"). Exact match on the + // stored ConnectionType column. Sessions that predate the projection lack the + // property and are excluded (acceptable — they have no connection data). + public string? ConnectionType { get; set; } + public int Limit { get; set; } = 50; // Dynamic device property filters (key = "eventType.propertyName", value = filter expression) diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index a6b666131..435a1608 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -31,6 +31,7 @@ const ALL_COLUMNS: ColumnDef[] = [ { key: "started", label: "Started", defaultVisible: true, sortKey: "startedAt" }, { key: "country", label: "Country", defaultVisible: false, sortKey: "geoCountry", filterKey: "geoCountry" }, { key: "apiLatency", label: "API Latency", defaultVisible: false, sortKey: "avgApiLatencyMs" }, + { key: "connection", label: "Connection", defaultVisible: false, sortKey: "connectionType", filterKey: "connectionType" }, { key: "agentVersion", label: "Agent Version", defaultVisible: false, sortKey: "agentVersion", filterKey: "agentVersion" }, { key: "osName", label: "OS Name", defaultVisible: false, sortKey: "osName", filterKey: "osName" }, { key: "osBuild", label: "OS Build", defaultVisible: false, sortKey: "osBuild", filterKey: "osBuild" }, @@ -641,7 +642,7 @@ export function SessionTable({ currentSort={sortColumn} direction={sortDirection} onSort={onSort} - className={["eventCount", "duration", "started", "country", "apiLatency", "agentVersion", "osName", "osBuild", "osDisplayVersion", "osEdition", "osLanguage"].includes(col.key) ? "px-3" : undefined} + className={["eventCount", "duration", "started", "country", "apiLatency", "connection", "agentVersion", "osName", "osBuild", "osDisplayVersion", "osEdition", "osLanguage"].includes(col.key) ? "px-3" : undefined} filterKey={col.filterKey} filterValues={col.filterKey ? uniqueValuesByField[col.filterKey] : undefined} activeFilter={col.filterKey ? columnFilters[col.filterKey] : undefined} @@ -922,6 +923,19 @@ function SessionCell({ ); + case "connection": + return ( + + {session.connectionType ? ( + + {session.connectionType} + + ) : ( + — + )} + + ); + case "agentVersion": { const fullVersion = session.agentVersion || ""; const shortVersion = fullVersion.split("+")[0]; diff --git a/src/Web/autopilot-monitor-web/types/session.ts b/src/Web/autopilot-monitor-web/types/session.ts index b5cd53db..6805b305 100644 --- a/src/Web/autopilot-monitor-web/types/session.ts +++ b/src/Web/autopilot-monitor-web/types/session.ts @@ -47,6 +47,8 @@ export interface Session { avgApiLatencyMs?: number; /** Number of HTTP requests behind avgApiLatencyMs — the weight when averaging across sessions. */ apiRequestCount?: number; + /** Active network connection type during enrollment ("WiFi" or "Ethernet", last emission wins). Absent for sessions predating the projection. */ + connectionType?: string; /** * Compact JSON snapshot of "last known session state" written by the maintenance * 5h-timeout sweep when a session graduates to terminal Failed (Hybrid User-Driven