mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
@@ -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"));
|
||||
|
||||
+4
@@ -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]
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user