feat: project ConnectionType (WiFi/Ethernet) onto SessionsIndex

The agent already reports connectionType in every network_interface_info
event, but it was only stored as a JSON blob in DeviceSnapshot — answering
"how many machines enroll over Ethernet vs WiFi?" required a slow full-table
scan via deviceProperties. Project it as a typed column instead (template:
the AvgApiLatencyMs chain), making it a cheap indexed one-call query.

- Ingest: TryExtractConnectionType — last network_interface_info emission of
  the batch wins (a device switching media mid-enrollment is relabeled);
  only exact "WiFi"/"Ethernet" accepted, fire-and-forget fail-soft writer
- Storage: UpdateSessionConnectionTypeAsync (Merge on Sessions + index
  mirror, tombstone-safe), BuildSessionIndexEntity superset entry,
  MapToSessionSummary, re-registration preserve in StoreSessionAsync
- Search: connectionType= filter with OData eq push-down on the index,
  client-side backstop on the non-OData paths, fields=connectionType
  projection, pagination fingerprint entry, raw endpoint parity
- MCP: connectionType z.enum param on search_sessions/query_raw_sessions,
  fields doc updated (server redeploy required)
- Web: optional sortable/filterable "Connection" column in the session list
- No backfill: legacy sessions lack the column and are excluded by the
  filter (intended, same semantics as RebootCount); the deviceProperties
  scan path still covers historical data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-28 14:25:17 +02:00
parent 8608ee6272
commit dff1532be5
18 changed files with 359 additions and 8 deletions
@@ -0,0 +1,48 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
/// a set filter; an unset filter matches everything.
/// </summary>
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));
}
}
@@ -0,0 +1,124 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the per-session connection-type projection (2026-07-28): the agent stamps
/// <c>connectionType</c> ("WiFi"/"Ethernet") into every <c>network_interface_info</c>
/// emission (initial collect + re-collect on change); ingest takes the LAST matching
/// event of the batch and persists it via <c>UpdateSessionConnectionTypeAsync</c>
/// (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.
/// </summary>
public class IngestConnectionTypeProjectionTests
{
private static EnrollmentEvent NicInfo(string? connectionType)
{
var evt = new EnrollmentEvent
{
EventType = "network_interface_info",
Data = new Dictionary<string, object>
{
["adapterName"] = "Ethernet 2",
["linkSpeedMbps"] = 1000L,
},
};
if (connectionType != null) evt.Data["connectionType"] = connectionType;
return evt;
}
[Fact]
public void EthernetEmission_IsExtracted()
{
var events = new List<EnrollmentEvent> { 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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
new EnrollmentEvent
{
EventType = "network_interface_info",
Data = new Dictionary<string, object> { ["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<EnrollmentEvent>
{
NicInfo("WiFi"),
new EnrollmentEvent
{
EventType = "network_interface_info",
Data = new Dictionary<string, object> { ["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<EnrollmentEvent> { NicInfo(raw) };
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
}
[Fact]
public void BatchWithoutNicEvents_DoesNotMatch()
{
var events = new List<EnrollmentEvent>
{
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<EnrollmentEvent>
{
new EnrollmentEvent { EventType = "network_interface_info", Data = null! },
};
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
}
}
@@ -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"));
@@ -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]
@@ -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<List<SessionSummary>> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold)
=> _storage.GetSessionsWithEventCountAboveAsync(tenantId, threshold);
@@ -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);
@@ -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)
{
@@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Pagination
new KeyValuePair<string, string?>("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("connectionType", filter.ConnectionType),
};
if (filter.DeviceProperties != null)
{
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
internal static bool TryExtractConnectionType(
List<EnrollmentEvent> 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;
}
/// <summary>
/// Numeric reader for agent event Data values, which arrive as boxed Newtonsoft
/// primitives (integer → long, decimal → double) — same coercion ladder as
@@ -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;
}
/// <summary>
/// Client-side ConnectionType predicate — same backstop role as
/// <see cref="MatchesRebootCountBounds"/>: 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.
/// </summary>
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();
}
@@ -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
}
}
/// <summary>
/// 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 <see cref="UpdateSessionNetworkLatencyAsync"/>:
/// a tombstoned Sessions row must stay tombstoned (404 → no-op).
/// Non-fatal: failures are logged as warnings and do not block ingest.
/// </summary>
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<TableEntity>(
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 =====
/// <summary>
@@ -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
@@ -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);
@@ -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. ' +
@@ -115,6 +115,13 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
double avgLatencyMs, int requestCount);
/// <summary>
/// Persists the active network connection type ("WiFi" or "Ethernet") from the
/// latest <c>network_interface_info</c> event. Last-write-wins; fail-soft like
/// the other supplementary-field updaters.
/// </summary>
Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId,
string connectionType);
// --- Server→Agent Actions ---
/// <summary>
@@ -275,6 +275,14 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int? ApiRequestCount { get; set; }
/// <summary>
/// Active network connection type during enrollment: "WiFi" or "Ethernet".
/// Projected at ingest from the latest <c>network_interface_info</c> event
/// (last emission wins — a device that switches media mid-enrollment reports
/// the most recent state). Null for sessions that predate the projection.
/// </summary>
public string? ConnectionType { get; set; }
/// <summary>
/// Enrollment type: "v1" (Autopilot Classic/ESP) or "v2" (Windows Device Preparation).
/// Defaults to "v1" for sessions that predate this field.
@@ -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)
@@ -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({
</td>
);
case "connection":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{session.connectionType ? (
<span title="Active network connection during enrollment (last reported state)">
{session.connectionType}
</span>
) : (
<span className="text-gray-300">—</span>
)}
</td>
);
case "agentVersion": {
const fullVersion = session.agentVersion || "";
const shortVersion = fullVersion.split("+")[0];
@@ -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