restructured source code for better maintenance and added ai files

This commit is contained in:
Oliver Kieselbach
2026-03-18 01:15:46 +01:00
parent b3ad327f31
commit a37a6b82e8
45 changed files with 1794 additions and 2230 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Autopilot Monitor provides real-time tracking, intelligent diagnostics, and auto
## Architecture
For detailed information about the system architecture, components, and data flow, see [Architecture Documentation](ARCHITECTURE.md).
For detailed information about the system architecture, components, and data flow, see [Architecture Documentation](docs/architecture.md).
## Documentation
+30
View File
@@ -0,0 +1,30 @@
# ADR-001: On-Demand SAS URL Delivery for Diagnostics Upload
**Status:** Accepted
**Date:** 2026-02
**Supersedes:** Original design with SAS URL in agent config response stored on disk
## Context
The agent needs to upload diagnostics ZIP packages to Azure Blob Storage. The original design included the long-lived SAS URL directly in the `agent/config` response, which was then persisted in `remote-config.json` on the device.
**Problems with the original approach:**
- SAS URL persisted on disk in plaintext (`remote-config.json`) — accessible to any local admin
- URL visible in agent logs
- No per-device access control — every device with the cached URL could upload at any time
## Decision
Instead of delivering the SAS URL via the config response, the agent calls `POST /api/diagnostics/upload-url` immediately before each upload. The backend returns the long-lived SAS URL (configured in tenant settings) in the response body. The agent uses it in-memory for the upload and discards it — it is never written to disk or cached in config.
**Config response changes:**
- Removed: `DiagnosticsBlobSasUrl` from agent config delivery
- Added: `DiagnosticsUploadEnabled` (bool) — controls whether the agent attempts upload
- Added: `POST /api/diagnostics/upload-url` endpoint (`GetDiagnosticsUploadUrlFunction`)
## Consequences
- SAS URL never persisted on device — only held in-memory for the duration of the upload
- Backend can deny the URL per-device (blocked devices, rate limiting, disabled tenants)
- Old agents that still read from `remote-config.json` fall back gracefully — they won't have a SAS URL and simply skip upload
- Slightly higher latency (one extra HTTP call before upload) — acceptable since uploads are infrequent
@@ -0,0 +1,33 @@
# ADR-002: Activity-Aware Idle Timeout Replaces Static Max Duration
**Status:** Accepted
**Date:** 2026-02
**Supersedes:** Static `MaxCollectorDurationHours` (4h hard cutoff)
## Context
The original design stopped all collectors after a fixed 4-hour window (`MaxCollectorDurationHours`). This caused problems:
- Long enrollments with many apps (>50) were cut off mid-collection
- Sessions with slow network or large downloads lost data
- No way to distinguish "still actively enrolling" from "enrollment finished, agent idle"
## Decision
Replace the static cutoff with an activity-aware idle timeout:
- **`CollectorIdleTimeoutMinutes`** (default: 15) — stops collectors when no "real" events arrive for this duration
- **"Real" events** = everything except `performance_snapshot`, `agent_metrics_snapshot`, and their `_stopped` variants
- Collectors auto-restart when new real activity arrives after idle stop
- `AgentMaxLifetimeMinutes` (default: 360/6h) remains as an absolute safety net
**Implementation:**
- `MonitoringService` tracks `_lastRealEventTime`
- Idle check timer fires every 60 seconds
- Global setting in `AdminConfiguration.CollectorIdleTimeoutMinutes`, delivered via `CollectorConfiguration`
## Consequences
- Long enrollments run to completion — no arbitrary cutoff
- Idle devices stop wasting CPU/network within 15 minutes
- Old agents (not updated) fall back to their built-in 4h default — no breaking change
- The 6h max lifetime timer prevents runaway agents regardless
@@ -0,0 +1,43 @@
# ADR-003: Multi-Signal Session Completion
**Status:** Accepted
**Date:** 2026-03
## Context
Windows Autopilot enrollment can follow multiple paths, and no single event reliably indicates "enrollment is done" across all scenarios:
- **ESP (Enrollment Status Page)** may or may not be present
- **Windows Hello** provisioning may be skipped, succeed, or time out
- **Windows Device Preparation (v2)** has no ESP at all
- **White Glove (Pre-Provisioning)** splits enrollment into two distinct phases
- Devices may reboot mid-enrollment, losing in-memory state
## Decision
Implement 3 independent completion paths, any of which can trigger `enrollment_complete`:
1. **IME Pattern Path** — The existing pattern-based detection from Intune Management Extension logs (legacy, most reliable for app-centric enrollments)
2. **ESP + Hello Composite Path** — ESP final exit event + Hello completion/skip/timeout. For device-only ESP (no Account Setup), a 5-minute timer after Device Setup exit classifies as device-only.
3. **Desktop + Hello Path (No-ESP)** — Desktop arrival (explorer.exe under real user) + Hello completion. Used when ESP is absent (e.g., WDP v2).
**Supporting infrastructure:**
- `EnrollmentStatePersistence` — crash recovery for completion signals (persists seen signals to disk)
- `DesktopArrivalDetector` — polls explorer.exe every 30s, validates user identity (excludes SYSTEM, DefaultUser*)
- Signal audit trail — terminal events include `signalsSeen` and `signalTimestamps`
- Completion check throttling — observability events at 1x/min/source
- WDP v2 gate skip — `enrollmentType == "v2"` skips desktop arrival gate
**Hello behavior:**
- `HelloCompletionTimeoutSeconds` = 300 (5 minutes)
- `HelloOutcome` tracks: `completed`, `skipped`, `timed_out`
## Consequences
- Covers all known Autopilot enrollment paths (Classic ESP, WDP v2, White Glove, device-only)
- State persists across reboots — no lost completions
- Each path is independently testable
- Adding new completion signals requires only extending `EnrollmentTracker.CompletionLogic.cs`
- Complexity concentrated in one partial class file (~789 lines) — acceptable given the domain complexity
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class CommandCollector : IGatherRuleCollector
{
public string CollectorType => "command_allowlisted";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var command = rule.Target;
if (string.IsNullOrEmpty(command))
return data;
// SECURITY: Check command against allowlist
if (!GatherRuleGuards.IsCommandAllowed(command, context.UnrestrictedMode))
{
context.Logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})");
data["blocked"] = true;
data["reason"] = "Command not on allowlist";
data["command"] = command;
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
Data = data
});
return data;
}
try
{
var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase);
ProcessStartInfo psi;
if (isPowerShell)
{
// Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection
var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
else
{
psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c {command}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
using (var process = Process.Start(psi))
{
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(30000); // 30 second timeout
data["command"] = command;
data["exit_code"] = process.ExitCode;
if (!string.IsNullOrWhiteSpace(output))
{
data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output;
}
if (!string.IsNullOrWhiteSpace(error))
{
data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error;
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
}
}
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class EventLogCollector : IGatherRuleCollector
{
public string CollectorType => "eventlog";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var logName = rule.Target;
if (string.IsNullOrEmpty(logName))
return data;
// Optional: escalate severity when matching entries are found
string severityIfFound = null;
rule.Parameters?.TryGetValue("severityIfFound", out severityIfFound);
try
{
int maxEntries = 10;
if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr))
{
int.TryParse(maxEntriesStr, out maxEntries);
maxEntries = Math.Min(maxEntries, 50); // Cap at 50
}
// Build XPath query with optional filters
var conditions = new List<string>();
string sourceFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter)
&& !string.IsNullOrEmpty(sourceFilter))
{
conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']");
}
string eventIdFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter)
&& !string.IsNullOrEmpty(eventIdFilter))
{
conditions.Add($"EventID={eventIdFilter}");
}
string xpath = conditions.Count > 0
? $"*[System[{string.Join(" and ", conditions)}]]"
: "*";
string messageFilter = null;
rule.Parameters?.TryGetValue("messageFilter", out messageFilter);
var query = new EventLogQuery(logName, PathType.LogName, xpath)
{
ReverseDirection = true // newest first
};
data["log_name"] = logName;
var entries = new List<string>();
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries)
{
using (record)
{
var message = "";
try { message = record.FormatDescription() ?? ""; }
catch { /* Some events lack formatting resources */ }
// Apply message filter (wildcard contains)
if (!string.IsNullOrEmpty(messageFilter))
{
var pattern = messageFilter.Trim('*');
if (string.IsNullOrEmpty(pattern) ||
message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0)
continue;
}
var level = "";
try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; }
catch { level = record.Level?.ToString() ?? "Unknown"; }
var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message;
entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}");
}
}
}
for (int i = 0; i < entries.Count; i++)
{
data[$"entry_{i + 1}"] = entries[i];
}
data["entries_returned"] = entries.Count;
// Apply conditional severity when matching entries are found
if (entries.Count > 0 && !string.IsNullOrEmpty(severityIfFound))
data["_severityOverride"] = severityIfFound;
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private static string EscapeXPath(string value)
{
return value.Replace("'", "&apos;");
}
}
}
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class FileCollector : IGatherRuleCollector
{
public string CollectorType => "file";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
// Expand environment variables
filePath = Environment.ExpandEnvironmentVariables(filePath);
// Guard: only allow enrollment-relevant file paths
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode))
return context.EmitSecurityWarning(rule, "file", filePath);
// Optional conditional severity based on existence
string severityIfExists = null;
string severityIfNotExists = null;
rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists);
rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists);
try
{
if (File.Exists(filePath))
{
var info = new FileInfo(filePath);
data["exists"] = true;
data["path"] = filePath;
data["size_bytes"] = info.Length;
data["last_modified"] = info.LastWriteTimeUtc.ToString("o");
data["created"] = info.CreationTimeUtc.ToString("o");
// Read content if requested and file is small enough
string readContent;
if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) &&
readContent == "true" && info.Length < 50000) // Max 50KB
{
// Read the last 4000 chars — most useful for log files where recent entries matter
const int maxChars = 4000;
string content;
bool truncated = false;
using (var reader = new StreamReader(filePath))
{
var full = reader.ReadToEnd();
if (full.Length > maxChars)
{
content = full.Substring(full.Length - maxChars);
truncated = true;
}
else
{
content = full;
}
}
data["content"] = truncated ? "(truncated) ..." + content : content;
}
}
else if (Directory.Exists(filePath))
{
data["exists"] = true;
data["is_directory"] = true;
data["path"] = filePath;
var files = Directory.GetFiles(filePath);
data["file_count"] = files.Length;
data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20));
}
else
{
data["exists"] = false;
data["path"] = filePath;
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
// Apply conditional severity based on existence
if (data.ContainsKey("exists"))
{
var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True";
if (exists && !string.IsNullOrEmpty(severityIfExists))
data["_severityOverride"] = severityIfExists;
else if (!exists && !string.IsNullOrEmpty(severityIfNotExists))
data["_severityOverride"] = severityIfNotExists;
}
return data;
}
}
}
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
/// <summary>
/// Shared dependencies passed to each gather rule collector.
/// </summary>
public class GatherRuleContext
{
public AgentLogger Logger { get; }
public string SessionId { get; }
public string TenantId { get; }
public Action<EnrollmentEvent> OnEventCollected { get; }
public string ImeLogPathOverride { get; }
public LogFilePositionTracker FilePositionTracker { get; }
public bool UnrestrictedMode { get; set; }
public GatherRuleContext(
AgentLogger logger,
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
string imeLogPathOverride,
LogFilePositionTracker filePositionTracker)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
ImeLogPathOverride = imeLogPathOverride;
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
}
/// <summary>
/// Emits a security_warning event and returns an empty result dictionary.
/// Called when a gather rule targets a path/query not on the allowlist.
/// </summary>
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
{
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
var data = new Dictionary<string, object>
{
["blocked"] = true,
["reason"] = $"{collectorType} target not on allowlist",
["target"] = target,
["ruleId"] = rule.RuleId,
};
OnEventCollected(new EnrollmentEvent
{
SessionId = SessionId,
TenantId = TenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
Data = data
});
return new Dictionary<string, object>();
}
/// <summary>
/// Parses a severity string into an EventSeverity enum value.
/// </summary>
public static EventSeverity ParseSeverity(string severity)
{
if (string.IsNullOrEmpty(severity))
return EventSeverity.Info;
switch (severity.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
default: return EventSeverity.Info;
}
}
}
}
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
/// <summary>
/// Strategy interface for individual gather rule collector implementations.
/// </summary>
public interface IGatherRuleCollector
{
string CollectorType { get; }
Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context);
}
}
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class JsonCollector : IGatherRuleCollector
{
public string CollectorType => "json";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
filePath = Environment.ExpandEnvironmentVariables(filePath);
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode))
return context.EmitSecurityWarning(rule, "json", filePath);
string jsonPath;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("jsonpath", out jsonPath) ||
string.IsNullOrEmpty(jsonPath))
{
context.Logger.Warning($"JSON rule {rule.RuleId} has no 'jsonpath' parameter");
data["error"] = "Missing required 'jsonpath' parameter";
return data;
}
int maxResults = 20;
string maxResultsStr;
if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr))
int.TryParse(maxResultsStr, out maxResults);
maxResults = Math.Min(maxResults, 100);
try
{
if (!File.Exists(filePath))
{
data["exists"] = false;
data["path"] = filePath;
return data;
}
var info = new FileInfo(filePath);
if (info.Length > 200 * 1024) // 200 KB limit
{
data["error"] = $"File too large ({info.Length} bytes, max 200 KB)";
data["path"] = filePath;
return data;
}
string content;
using (var reader = new StreamReader(filePath))
{
content = reader.ReadToEnd();
}
var token = JToken.Parse(content);
var results = token.SelectTokens(jsonPath).Take(maxResults).ToList();
data["path"] = filePath;
data["query"] = jsonPath;
data["matchCount"] = results.Count;
if (results.Count == 1)
{
data["value"] = TruncateMessage(results[0].ToString(), 2000);
}
if (results.Count > 0)
{
var matchStrings = results.Select(r => TruncateMessage(r.ToString(), 500)).ToList();
data["matches"] = string.Join("\n---\n", matchStrings);
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
data["path"] = filePath;
data["query"] = jsonPath;
}
return data;
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
}
}
@@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class LogParserCollector : IGatherRuleCollector
{
public string CollectorType => "logparser";
/// <summary>
/// Executes the log parser rule. Returns null because logparser emits events directly
/// via context.OnEventCollected rather than returning a single result dictionary.
/// </summary>
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return null;
filePath = Environment.ExpandEnvironmentVariables(filePath);
// Apply IME log path override if set
if (!string.IsNullOrEmpty(context.ImeLogPathOverride))
{
var fileName = Path.GetFileName(filePath);
filePath = Path.Combine(context.ImeLogPathOverride, fileName);
}
// Get parameters
string patternStr;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) ||
string.IsNullOrEmpty(patternStr))
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
return null;
}
string trackPositionStr;
bool trackPosition = true;
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
bool.TryParse(trackPositionStr, out trackPosition);
string maxLinesStr;
int maxLines = 1000;
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
int.TryParse(maxLinesStr, out maxLines);
// Determine format: "cmtrace" (default) or "text" for plain text logs
string formatStr;
bool isTextMode = false;
if (rule.Parameters.TryGetValue("format", out formatStr))
isTextMode = string.Equals(formatStr, "text", StringComparison.OrdinalIgnoreCase);
Regex pattern;
try
{
pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
return null;
}
// Resolve file paths — supports wildcards (* and ?) in the filename portion
var resolvedPaths = ResolveLogPaths(filePath, rule.RuleId, context);
if (resolvedPaths.Count == 0)
{
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
return null;
}
foreach (var resolvedPath in resolvedPaths)
{
ProcessLogFile(resolvedPath, rule, pattern, trackPosition, maxLines, isTextMode, context);
}
return null;
}
private static List<string> ResolveLogPaths(string filePath, string ruleId, GatherRuleContext context)
{
var fileNamePart = Path.GetFileName(filePath);
// No wildcards — single file
if (!fileNamePart.Contains("*") && !fileNamePart.Contains("?"))
{
if (File.Exists(filePath))
return new List<string> { filePath };
return new List<string>();
}
// Wildcard expansion
var directory = Path.GetDirectoryName(filePath);
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
return new List<string>();
try
{
// Return matched files sorted by last write time (newest first), capped at 20
return Directory.GetFiles(directory, fileNamePart)
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
.Take(20)
.ToList();
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
return new List<string>();
}
}
private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern,
bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context)
{
try
{
var fileInfo = new FileInfo(filePath);
long startPosition = trackPosition
? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length)
: 0;
// Nothing new to read
if (startPosition >= fileInfo.Length)
return;
int matchCount = 0;
int linesRead = 0;
long endPosition = startPosition;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
stream.Seek(startPosition, SeekOrigin.Begin);
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
string line;
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
{
linesRead++;
if (isTextMode)
{
// Text mode: match regex directly against the raw line
Match match;
try
{
match = pattern.Match(line);
}
catch (RegexMatchTimeoutException)
{
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
data["logLine"] = TruncateMessage(line, 500);
data["logLineNumber"] = linesRead;
data["logFile"] = Path.GetFileName(filePath);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
}
else
{
// CMTrace mode: parse line as CMTrace format, match regex against message
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
continue;
Match match;
try
{
match = pattern.Match(entry.Message);
}
catch (RegexMatchTimeoutException)
{
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
data["logTimestamp"] = entry.Timestamp.ToString("o");
data["logComponent"] = entry.Component;
data["logType"] = entry.Type;
data["logMessage"] = TruncateMessage(entry.Message, 500);
data["logFile"] = Path.GetFileName(filePath);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = entry.Timestamp,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
}
}
// Capture the final stream position after reading
endPosition = stream.Position;
}
}
if (trackPosition)
context.FilePositionTracker.SetPosition(filePath, endPosition);
if (matchCount > 0)
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
}
}
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
{
// If the rule specifies a severity, use it
if (!string.IsNullOrEmpty(ruleOverride))
{
switch (ruleOverride.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
}
}
// Otherwise derive from CMTrace log type
switch (cmTraceType)
{
case 2: return EventSeverity.Warning;
case 3: return EventSeverity.Error;
default: return EventSeverity.Info;
}
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
}
}
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class RegistryCollector : IGatherRuleCollector
{
public string CollectorType => "registry";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var path = rule.Target;
if (string.IsNullOrEmpty(path))
return data;
// Determine hive
RegistryKey hive = null;
string subPath = path;
if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.LocalMachine;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.CurrentUser;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else
{
// Default to HKLM
hive = Registry.LocalMachine;
}
// Guard: only allow enrollment-relevant registry paths
if (!GatherRuleGuards.IsRegistryPathAllowed(subPath, context.UnrestrictedMode))
return context.EmitSecurityWarning(rule, "registry", path);
// Determine explicit valueName from parameters
string explicitValueName = null;
rule.Parameters?.TryGetValue("valueName", out explicitValueName);
// Check if subkey enumeration is requested
string listSubkeysStr = null;
rule.Parameters?.TryGetValue("listSubkeys", out listSubkeysStr);
bool listSubkeys = string.Equals(listSubkeysStr, "true", StringComparison.OrdinalIgnoreCase);
// Optional conditional severity based on existence
string severityIfExists = null;
string severityIfNotExists = null;
rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists);
rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists);
try
{
using (var key = hive.OpenSubKey(subPath, false))
{
if (key == null)
{
// The key doesn't exist as a sub-key. If no explicit valueName was given,
// check whether the last path segment is a value name in the parent key.
// This handles the common case where the user specifies the full
// "HKLM\...\KeyName\ValueName" path directly in Target.
if (string.IsNullOrEmpty(explicitValueName) && !listSubkeys)
{
var lastBackslash = subPath.LastIndexOf('\\');
if (lastBackslash > 0)
{
var parentSubPath = subPath.Substring(0, lastBackslash);
var inferredValueName = subPath.Substring(lastBackslash + 1);
using (var parentKey = hive.OpenSubKey(parentSubPath, false))
{
if (parentKey != null)
{
var valueNames = parentKey.GetValueNames();
if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase)))
{
var value = parentKey.GetValue(inferredValueName);
data["exists"] = true;
data["path"] = path.Substring(0, path.LastIndexOf('\\'));
data[inferredValueName] = value?.ToString();
return data;
}
}
}
}
}
data["exists"] = false;
data["path"] = path;
return data;
}
data["exists"] = true;
data["path"] = path;
if (listSubkeys)
{
// Enumerate subkey names (max 100)
var subKeyNames = key.GetSubKeyNames().Take(100).ToArray();
data["subkey_count"] = subKeyNames.Length;
for (int i = 0; i < subKeyNames.Length; i++)
{
data[$"subkey_{i + 1}"] = subKeyNames[i];
}
}
else if (!string.IsNullOrEmpty(explicitValueName))
{
// Read specific value if specified in parameters
var value = key.GetValue(explicitValueName);
data[explicitValueName] = value?.ToString();
}
else
{
// Read all values
foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values
{
try
{
var value = key.GetValue(name);
var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name;
data[displayName] = value?.ToString();
}
catch { }
}
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
// Apply conditional severity based on existence
if (data.ContainsKey("exists"))
{
var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True";
if (exists && !string.IsNullOrEmpty(severityIfExists))
data["_severityOverride"] = severityIfExists;
else if (!exists && !string.IsNullOrEmpty(severityIfNotExists))
data["_severityOverride"] = severityIfNotExists;
}
return data;
}
}
}
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Management;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class WmiCollector : IGatherRuleCollector
{
public string CollectorType => "wmi";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var query = rule.Target;
if (string.IsNullOrEmpty(query))
return data;
// Guard: only allow known-safe WMI classes
if (!GatherRuleGuards.IsWmiQueryAllowed(query, context.UnrestrictedMode))
return context.EmitSecurityWarning(rule, "wmi", query);
try
{
using (var searcher = new ManagementObjectSearcher(query))
{
int index = 0;
foreach (ManagementObject obj in searcher.Get())
{
var item = new Dictionary<string, object>();
foreach (var prop in obj.Properties)
{
try
{
item[prop.Name] = prop.Value?.ToString();
}
catch { }
}
if (item.Count > 0)
{
// For single result, flatten; for multiple, use indexed keys
if (index == 0)
{
foreach (var kvp in item)
data[kvp.Key] = kvp.Value;
}
else
{
data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}"));
}
}
index++;
if (index >= 20) break; // Limit results
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
}
}
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors
{
public class XmlCollector : IGatherRuleCollector
{
public string CollectorType => "xml";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
filePath = Environment.ExpandEnvironmentVariables(filePath);
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode))
return context.EmitSecurityWarning(rule, "xml", filePath);
string xpath;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("xpath", out xpath) ||
string.IsNullOrEmpty(xpath))
{
context.Logger.Warning($"XML rule {rule.RuleId} has no 'xpath' parameter");
data["error"] = "Missing required 'xpath' parameter";
return data;
}
int maxResults = 20;
string maxResultsStr;
if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr))
int.TryParse(maxResultsStr, out maxResults);
maxResults = Math.Min(maxResults, 100);
try
{
if (!File.Exists(filePath))
{
data["exists"] = false;
data["path"] = filePath;
return data;
}
var info = new FileInfo(filePath);
if (info.Length > 200 * 1024) // 200 KB limit
{
data["error"] = $"File too large ({info.Length} bytes, max 200 KB)";
data["path"] = filePath;
return data;
}
// Parse XML with DTD processing disabled (XXE prevention)
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
};
XDocument doc;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var xmlReader = XmlReader.Create(stream, settings))
{
doc = XDocument.Load(xmlReader);
}
// Set up namespace resolver if namespaces are provided
XmlNamespaceManager nsManager = null;
string namespacesStr;
if (rule.Parameters.TryGetValue("namespaces", out namespacesStr) &&
!string.IsNullOrEmpty(namespacesStr))
{
nsManager = new XmlNamespaceManager(new NameTable());
foreach (var ns in namespacesStr.Split(';'))
{
var parts = ns.Split(new[] { '=' }, 2);
if (parts.Length == 2 && !string.IsNullOrEmpty(parts[0]) && !string.IsNullOrEmpty(parts[1]))
{
nsManager.AddNamespace(parts[0].Trim(), parts[1].Trim());
}
}
}
// Execute XPath — try element selection first, fall back to evaluate for scalar/attribute results
var elements = nsManager != null
? doc.XPathSelectElements(xpath, nsManager).Take(maxResults).ToList()
: doc.XPathSelectElements(xpath).Take(maxResults).ToList();
data["path"] = filePath;
data["query"] = xpath;
if (elements.Count > 0)
{
data["matchCount"] = elements.Count;
if (elements.Count == 1)
{
data["value"] = TruncateMessage(elements[0].ToString(), 2000);
}
var matchStrings = elements.Select(e => TruncateMessage(e.ToString(), 500)).ToList();
data["matches"] = string.Join("\n---\n", matchStrings);
}
else
{
// Try XPathEvaluate for scalar results (attributes, text(), count(), etc.)
var evalResult = nsManager != null
? doc.XPathEvaluate(xpath, nsManager)
: doc.XPathEvaluate(xpath);
if (evalResult is IEnumerable<object> enumerable)
{
var items = enumerable.Take(maxResults).ToList();
data["matchCount"] = items.Count;
if (items.Count == 1)
{
data["value"] = TruncateMessage(items[0].ToString(), 2000);
}
if (items.Count > 0)
{
data["matches"] = string.Join("\n---\n",
items.Select(i => TruncateMessage(i.ToString(), 500)));
}
}
else if (evalResult != null)
{
data["matchCount"] = 1;
data["value"] = TruncateMessage(evalResult.ToString(), 2000);
}
else
{
data["matchCount"] = 0;
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
data["path"] = filePath;
data["query"] = xpath;
}
return data;
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
}
}
@@ -1,17 +1,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.IO;
using System.Linq;
using System.Management;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Collectors.GatherCollectors;
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
@@ -19,35 +13,62 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Executes gather rules received from the backend API
/// Supports registry, eventlog, wmi, file, command_allowlisted, logparser, json, and xml collector types
/// </summary>
public partial class GatherRuleExecutor : IDisposable
public class GatherRuleExecutor : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly string _imeLogPathOverride;
private readonly GatherRuleContext _context;
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
private List<GatherRule> _activeRules = new List<GatherRule>();
private readonly Dictionary<string, Timer> _intervalTimers = new Dictionary<string, Timer>();
private readonly HashSet<string> _startupRulesExecuted = new HashSet<string>();
private readonly HashSet<string> _phaseRulesExecuted = new HashSet<string>();
private readonly LogFilePositionTracker _filePositionTracker = new LogFilePositionTracker();
private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending
/// <summary>
/// When true, guardrails are relaxed: all registry, WMI, and command targets are allowed.
/// File paths allow everything except C:\Users. Set from tenant configuration.
/// </summary>
public bool UnrestrictedMode { get; set; } = false;
public bool UnrestrictedMode
{
get { return _context.UnrestrictedMode; }
set { _context.UnrestrictedMode = value; }
}
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, string imeLogPathOverride = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_imeLogPathOverride = imeLogPathOverride;
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
if (onEventCollected == null) throw new ArgumentNullException(nameof(onEventCollected));
if (logger == null) throw new ArgumentNullException(nameof(logger));
_logger = logger;
var filePositionTracker = new LogFilePositionTracker();
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
// Register all collector strategies
var collectorList = new IGatherRuleCollector[]
{
new RegistryCollector(),
new WmiCollector(),
new CommandCollector(),
new FileCollector(),
new EventLogCollector(),
new LogParserCollector(),
new JsonCollector(),
new XmlCollector(),
};
_collectors = new Dictionary<string, IGatherRuleCollector>(StringComparer.OrdinalIgnoreCase);
foreach (var collector in collectorList)
{
_collectors[collector.CollectorType] = collector;
}
// Register legacy alias: "command" maps to the same collector as "command_allowlisted"
_collectors["command"] = _collectors["command_allowlisted"];
}
/// <summary>
@@ -151,49 +172,22 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
Dictionary<string, object> result = null;
var collectorType = rule.CollectorType?.ToLower();
switch (rule.CollectorType?.ToLower())
IGatherRuleCollector collector;
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
{
case "registry":
result = ExecuteRegistryRule(rule);
break;
case "wmi":
result = ExecuteWmiRule(rule);
break;
case "command_allowlisted":
case "command": // legacy alias - both enforce the allowlist
result = ExecuteCommandRule(rule);
break;
case "file":
result = ExecuteFileRule(rule);
break;
case "eventlog":
result = ExecuteEventLogRule(rule);
break;
case "json":
result = ExecuteJsonRule(rule);
break;
case "xml":
result = ExecuteXmlRule(rule);
break;
case "logparser":
ExecuteLogParserRule(rule);
return; // Return early - logparser emits events directly
default:
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
return;
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
return;
}
if (result != null && result.Count > 0)
Dictionary<string, object> result = collector.Execute(rule, _context);
// LogParser (and potentially others) emit events directly and return null
if (result == null)
return;
if (result.Count > 0)
{
result["ruleId"] = rule.RuleId;
result["ruleTitle"] = rule.Title;
@@ -205,18 +199,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
EventSeverity severity;
if (result.TryGetValue("_severityOverride", out severityOverride) && severityOverride is string sev)
{
severity = ParseSeverity(sev);
severity = GatherRuleContext.ParseSeverity(sev);
result.Remove("_severityOverride");
}
else
{
severity = ParseSeverity(rule.OutputSeverity);
severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
}
_onEventCollected(new EnrollmentEvent
_context.OnEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
SessionId = _context.SessionId,
TenantId = _context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
@@ -232,5 +226,42 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
private EventSeverity ParseSeverity(string severity)
{
return GatherRuleContext.ParseSeverity(severity);
}
private void StopAllTimers()
{
foreach (var timer in _intervalTimers.Values)
{
timer.Dispose();
}
_intervalTimers.Clear();
}
/// <summary>
/// Blocks until all startup rules that were queued by the most recent <see cref="UpdateRules"/>
/// call have finished executing, or the timeout elapses.
/// Returns true if all rules completed within the timeout; false if timed out.
/// </summary>
public bool WaitForStartupRules(int timeoutSeconds = 120)
{
var latch = _startupRulesLatch;
if (latch == null)
return true; // no startup rules were queued
_logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete...");
var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds));
_logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}");
return completed;
}
public void Dispose()
{
StopAllTimers();
_startupRulesLatch?.Dispose();
_startupRulesLatch = null;
}
}
}
@@ -1 +1 @@
472
475
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Request to create a new bootstrap session for OOBE agent deployment
/// </summary>
public class CreateBootstrapSessionRequest
{
public string TenantId { get; set; }
/// <summary>
/// How long the bootstrap URL should be valid (1–168 hours, default 8)
/// </summary>
public int ValidityHours { get; set; } = 8;
/// <summary>
/// Optional human-readable label (e.g. "Lab A", "Floor 3")
/// </summary>
public string Label { get; set; }
}
/// <summary>
/// Response after creating a bootstrap session
/// </summary>
public class CreateBootstrapSessionResponse
{
public bool Success { get; set; }
public string ShortCode { get; set; }
public string BootstrapUrl { get; set; }
public DateTime ExpiresAt { get; set; }
public string Message { get; set; }
}
/// <summary>
/// A single bootstrap session item for the list response
/// </summary>
public class BootstrapSessionListItem
{
public string ShortCode { get; set; }
public string Label { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public string CreatedByUpn { get; set; }
public bool IsRevoked { get; set; }
public bool IsExpired { get; set; }
public int UsageCount { get; set; }
}
/// <summary>
/// Response containing a list of bootstrap sessions for a tenant
/// </summary>
public class ListBootstrapSessionsResponse
{
public bool Success { get; set; }
public List<BootstrapSessionListItem> Sessions { get; set; } = new List<BootstrapSessionListItem>();
}
/// <summary>
/// Response from validating a bootstrap code (anonymous, called by the /go/{code} route)
/// </summary>
public class ValidateBootstrapCodeResponse
{
public bool Success { get; set; }
public string TenantId { get; set; }
public string Token { get; set; }
public string AgentDownloadUrl { get; set; }
public DateTime ExpiresAt { get; set; }
public string Message { get; set; }
}
}
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Response containing geographic performance metrics aggregated by location.
/// </summary>
public class GeographicMetricsResponse
{
public bool Success { get; set; }
public List<LocationMetrics> Locations { get; set; } = new();
public GlobalAverages GlobalAverages { get; set; } = new();
public DateTime ComputedAt { get; set; }
public int TotalSessions { get; set; }
public int LocationsWithData { get; set; }
/// <summary>Whether geo-location collection is enabled for this tenant</summary>
public bool GeoLocationEnabled { get; set; } = true;
}
/// <summary>
/// Performance metrics for a single geographic location.
/// </summary>
public class LocationMetrics
{
public string LocationKey { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Loc { get; set; } = string.Empty;
public int SessionCount { get; set; }
public int Succeeded { get; set; }
public int Failed { get; set; }
public double SuccessRate { get; set; }
public double AvgDurationMinutes { get; set; }
public double MedianDurationMinutes { get; set; }
public double P95DurationMinutes { get; set; }
/// <summary>Average number of apps installed per session at this location</summary>
public double AvgAppCount { get; set; }
/// <summary>Average minutes per app (AvgDurationMinutes / AvgAppCount)</summary>
public double MinutesPerApp { get; set; }
/// <summary>Normalized score: 100 = global median, lower is better</summary>
public double AppLoadScore { get; set; }
/// <summary>Average download throughput in bytes/sec at this location</summary>
public double AvgThroughputBytesPerSec { get; set; }
public long TotalDownloadBytes { get; set; }
/// <summary>Percentage difference from global avg duration (negative = faster)</summary>
public double DurationVsGlobalPct { get; set; }
/// <summary>Percentage difference from global avg throughput (positive = faster)</summary>
public double ThroughputVsGlobalPct { get; set; }
public bool IsOutlier { get; set; }
/// <summary>"fast", "slow", or null</summary>
public string OutlierDirection { get; set; }
}
/// <summary>
/// Global average benchmarks for geographic comparison.
/// </summary>
public class GlobalAverages
{
public double AvgDurationMinutes { get; set; }
public double MedianDurationMinutes { get; set; }
public double AvgMinutesPerApp { get; set; }
public double AvgThroughputBytesPerSec { get; set; }
public double StdDevDurationMinutes { get; set; }
}
}
@@ -0,0 +1,66 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Request to submit a session report for analysis by the Autopilot Monitor team.
/// Sent as JSON from the frontend; the backend creates the ZIP and uploads to central storage.
/// </summary>
public class SubmitSessionReportRequest
{
public string TenantId { get; set; }
public string SessionId { get; set; }
public string Comment { get; set; }
public string Email { get; set; }
/// <summary>Session row as CSV (single data row with header)</summary>
public string SessionCsv { get; set; }
/// <summary>Pre-generated UI timeline export (TXT)</summary>
public string TimelineExportTxt { get; set; }
/// <summary>Pre-generated raw events table export (CSV)</summary>
public string EventsCsv { get; set; }
/// <summary>Pre-generated analysis rule results export (CSV)</summary>
public string RuleResultsCsv { get; set; }
/// <summary>Base64-encoded screenshot image (optional)</summary>
public string ScreenshotBase64 { get; set; }
/// <summary>Original screenshot file name for extension detection</summary>
public string ScreenshotFileName { get; set; }
/// <summary>Base64-encoded agent log file (optional, max 5 MB)</summary>
public string AgentLogBase64 { get; set; }
/// <summary>Original agent log file name</summary>
public string AgentLogFileName { get; set; }
}
/// <summary>
/// Response from session report submission
/// </summary>
public class SubmitSessionReportResponse
{
public bool Success { get; set; }
public string Message { get; set; }
public string ReportId { get; set; }
}
/// <summary>
/// Session report metadata for the admin-config reports table
/// </summary>
public class SessionReportMetadata
{
public string ReportId { get; set; }
public string TenantId { get; set; }
public string SessionId { get; set; }
public string Comment { get; set; }
public string Email { get; set; }
public string BlobName { get; set; }
public string SubmittedBy { get; set; }
public DateTime SubmittedAt { get; set; }
public string AdminNote { get; set; }
}
}
@@ -223,205 +223,4 @@ namespace AutopilotMonitor.Shared.Models
Failed,
Unknown
}
/// <summary>
/// Request to submit a session report for analysis by the Autopilot Monitor team.
/// Sent as JSON from the frontend; the backend creates the ZIP and uploads to central storage.
/// </summary>
public class SubmitSessionReportRequest
{
public string TenantId { get; set; }
public string SessionId { get; set; }
public string Comment { get; set; }
public string Email { get; set; }
/// <summary>Session row as CSV (single data row with header)</summary>
public string SessionCsv { get; set; }
/// <summary>Pre-generated UI timeline export (TXT)</summary>
public string TimelineExportTxt { get; set; }
/// <summary>Pre-generated raw events table export (CSV)</summary>
public string EventsCsv { get; set; }
/// <summary>Pre-generated analysis rule results export (CSV)</summary>
public string RuleResultsCsv { get; set; }
/// <summary>Base64-encoded screenshot image (optional)</summary>
public string ScreenshotBase64 { get; set; }
/// <summary>Original screenshot file name for extension detection</summary>
public string ScreenshotFileName { get; set; }
/// <summary>Base64-encoded agent log file (optional, max 5 MB)</summary>
public string AgentLogBase64 { get; set; }
/// <summary>Original agent log file name</summary>
public string AgentLogFileName { get; set; }
}
/// <summary>
/// Response from session report submission
/// </summary>
public class SubmitSessionReportResponse
{
public bool Success { get; set; }
public string Message { get; set; }
public string ReportId { get; set; }
}
/// <summary>
/// Session report metadata for the admin-config reports table
/// </summary>
public class SessionReportMetadata
{
public string ReportId { get; set; }
public string TenantId { get; set; }
public string SessionId { get; set; }
public string Comment { get; set; }
public string Email { get; set; }
public string BlobName { get; set; }
public string SubmittedBy { get; set; }
public DateTime SubmittedAt { get; set; }
public string AdminNote { get; set; }
}
/// <summary>
/// Response containing geographic performance metrics aggregated by location.
/// </summary>
public class GeographicMetricsResponse
{
public bool Success { get; set; }
public List<LocationMetrics> Locations { get; set; } = new();
public GlobalAverages GlobalAverages { get; set; } = new();
public DateTime ComputedAt { get; set; }
public int TotalSessions { get; set; }
public int LocationsWithData { get; set; }
/// <summary>Whether geo-location collection is enabled for this tenant</summary>
public bool GeoLocationEnabled { get; set; } = true;
}
/// <summary>
/// Performance metrics for a single geographic location.
/// </summary>
public class LocationMetrics
{
public string LocationKey { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Loc { get; set; } = string.Empty;
public int SessionCount { get; set; }
public int Succeeded { get; set; }
public int Failed { get; set; }
public double SuccessRate { get; set; }
public double AvgDurationMinutes { get; set; }
public double MedianDurationMinutes { get; set; }
public double P95DurationMinutes { get; set; }
/// <summary>Average number of apps installed per session at this location</summary>
public double AvgAppCount { get; set; }
/// <summary>Average minutes per app (AvgDurationMinutes / AvgAppCount)</summary>
public double MinutesPerApp { get; set; }
/// <summary>Normalized score: 100 = global median, lower is better</summary>
public double AppLoadScore { get; set; }
/// <summary>Average download throughput in bytes/sec at this location</summary>
public double AvgThroughputBytesPerSec { get; set; }
public long TotalDownloadBytes { get; set; }
/// <summary>Percentage difference from global avg duration (negative = faster)</summary>
public double DurationVsGlobalPct { get; set; }
/// <summary>Percentage difference from global avg throughput (positive = faster)</summary>
public double ThroughputVsGlobalPct { get; set; }
public bool IsOutlier { get; set; }
/// <summary>"fast", "slow", or null</summary>
public string OutlierDirection { get; set; }
}
// -----------------------------------------------------------------------
// Bootstrap session API models (OOBE pre-enrollment)
// -----------------------------------------------------------------------
/// <summary>
/// Request to create a new bootstrap session for OOBE agent deployment
/// </summary>
public class CreateBootstrapSessionRequest
{
public string TenantId { get; set; }
/// <summary>
/// How long the bootstrap URL should be valid (1–168 hours, default 8)
/// </summary>
public int ValidityHours { get; set; } = 8;
/// <summary>
/// Optional human-readable label (e.g. "Lab A", "Floor 3")
/// </summary>
public string Label { get; set; }
}
/// <summary>
/// Response after creating a bootstrap session
/// </summary>
public class CreateBootstrapSessionResponse
{
public bool Success { get; set; }
public string ShortCode { get; set; }
public string BootstrapUrl { get; set; }
public DateTime ExpiresAt { get; set; }
public string Message { get; set; }
}
/// <summary>
/// A single bootstrap session item for the list response
/// </summary>
public class BootstrapSessionListItem
{
public string ShortCode { get; set; }
public string Label { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public string CreatedByUpn { get; set; }
public bool IsRevoked { get; set; }
public bool IsExpired { get; set; }
public int UsageCount { get; set; }
}
/// <summary>
/// Response containing a list of bootstrap sessions for a tenant
/// </summary>
public class ListBootstrapSessionsResponse
{
public bool Success { get; set; }
public List<BootstrapSessionListItem> Sessions { get; set; } = new List<BootstrapSessionListItem>();
}
/// <summary>
/// Response from validating a bootstrap code (anonymous, called by the /go/{code} route)
/// </summary>
public class ValidateBootstrapCodeResponse
{
public bool Success { get; set; }
public string TenantId { get; set; }
public string Token { get; set; }
public string AgentDownloadUrl { get; set; }
public DateTime ExpiresAt { get; set; }
public string Message { get; set; }
}
/// <summary>
/// Global average benchmarks for geographic comparison.
/// </summary>
public class GlobalAverages
{
public double AvgDurationMinutes { get; set; }
public double MedianDurationMinutes { get; set; }
public double AvgMinutesPerApp { get; set; }
public double AvgThroughputBytesPerSec { get; set; }
public double StdDevDurationMinutes { get; set; }
}
}
@@ -1,25 +1 @@
export interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
failureReason?: string;
isPreProvisioned?: boolean;
isHybridJoin?: boolean;
agentVersion?: string;
geoCountry?: string;
geoRegion?: string;
geoCity?: string;
osName?: string;
osBuild?: string;
osDisplayVersion?: string;
osEdition?: string;
osLanguage?: string;
}
export type { Session } from "@/types";
@@ -10,49 +10,7 @@ import { ProtectedRoute } from "../../../components/ProtectedRoute";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { ConfidenceBadge, SeverityBadge } from "./components/DiagnosisBadges";
interface EnrollmentEvent {
eventId: string;
sessionId: string;
timestamp: string;
eventType: string;
severity: string;
source: string;
phase: number;
phaseName?: string;
message: string;
sequence: number;
data?: Record<string, any>;
}
interface RuleResult {
resultId: string;
ruleId: string;
ruleTitle: string;
severity: string;
category: string;
confidenceScore: number;
explanation: string;
remediation: { title: string; steps: string[] }[];
relatedDocs: { title: string; url: string }[];
matchedConditions: Record<string, any>;
detectedAt: string;
}
interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
failureReason?: string;
}
import { Session, EnrollmentEvent, RuleResult } from "@/types";
export default function DiagnosisPage() {
const params = useParams();
@@ -10,21 +10,7 @@ import { useNotifications } from "../../contexts/NotificationContext";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import FleetStatCard from "./components/FleetStatCard";
interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
failureReason?: string;
}
import { Session } from "@/types";
interface AppMetric {
appName: string;
@@ -8,32 +8,7 @@ import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { useSignalR } from "../../contexts/SignalRContext";
import { ProtectedRoute } from "../../components/ProtectedRoute";
interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
failureReason?: string;
}
interface EnrollmentEvent {
eventId: string;
sessionId: string;
timestamp: string;
eventType: string;
phase: number;
message: string;
sequence: number;
data?: Record<string, any>;
}
import { Session, EnrollmentEvent } from "@/types";
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { RuleResult } from "../page";
import { RuleResult } from "@/types";
interface AnalysisResultsSectionProps {
analysisResults: RuleResult[];
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import { EnrollmentEvent } from "../page";
import { EnrollmentEvent } from "@/types";
export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
const [expanded, setExpanded] = useState(false);
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import { EnrollmentEvent, Session } from "../page";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay } from "../utils/eventHelpers";
interface EventTimelineProps {
@@ -1,6 +1,6 @@
"use client";
import { Session } from "../page";
import { Session } from "@/types";
interface MarkFailedModalProps {
show: boolean;
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { EnrollmentEvent } from "../page";
import { EnrollmentEvent } from "@/types";
import { V1_PHASES, V1_SKIP_USER_PHASES, V2_PHASES } from "../utils/phaseConstants";
interface PhaseTimelineProps {
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { zipSync } from "fflate";
import { Session, EnrollmentEvent, RuleResult } from "../page";
import { Session, EnrollmentEvent, RuleResult } from "@/types";
const MAX_AGENT_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Session } from "../page";
import { Session } from "@/types";
interface SessionInfoCardProps {
session: Session;
@@ -25,62 +25,7 @@ import ReportSessionModal from "./components/ReportSessionModal";
import SectionNav, { SectionEntry } from "./components/SectionNav";
import DeviceDetailsCard from "./components/DeviceDetailsCard";
import { generateUiExport, generateCsvExport, generateSessionCsvExport, generateRuleResultsCsvExport, SessionExportEvent } from "@/lib/sessionExportUtils";
export interface EnrollmentEvent {
eventId: string;
sessionId: string;
timestamp: string;
eventType: string;
severity: string;
source: string;
phase: number;
phaseName?: string;
message: string;
sequence: number;
data?: Record<string, any>;
}
export interface RuleResult {
resultId: string;
sessionId: string;
tenantId: string;
ruleId: string;
ruleTitle: string;
severity: string;
category: string;
confidenceScore: number;
explanation: string;
remediation: { title: string; steps: string[] }[];
relatedDocs: { title: string; url: string }[];
matchedConditions: Record<string, any>;
detectedAt: string;
}
export interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
completedAt?: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
failureReason?: string;
enrollmentType?: string; // "v1" | "v2" — absent for sessions before this feature
diagnosticsBlobName?: string;
lastEventAt?: string;
isPreProvisioned?: boolean;
isHybridJoin?: boolean;
osBuild?: string;
osEdition?: string;
osLanguage?: string;
isUserDriven?: boolean;
agentVersion?: string;
}
import { Session, EnrollmentEvent, RuleResult } from "@/types";
const GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -1,625 +0,0 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSignalR } from "../../../contexts/SignalRContext";
import { useTenant } from "../../../contexts/TenantContext";
import { API_BASE_URL } from "@/lib/config";
interface EnrollmentEvent {
eventId: string;
sessionId: string;
timestamp: string;
eventType: string;
severity: string;
source: string;
phase: number;
phaseName?: string;
message: string;
sequence: number;
data?: Record<string, any>;
}
interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
}
const phaseNames: Record<number, string> = {
0: "PreFlight",
1: "Network",
2: "Identity",
3: "MDM Enrollment",
4: "ESP Device Setup",
5: "App Installation",
6: "ESP User Setup",
7: "Complete",
99: "Failed"
};
export default function SessionDetailPage() {
const params = useParams();
const router = useRouter();
const sessionId = params?.sessionId as string;
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
const [showMarkFailedConfirm, setShowMarkFailedConfirm] = useState(false);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
}
return false;
});
// Use ref for debounce timeout to persist across renders
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const sessionIdRef = useRef(sessionId);
const hasInitialFetch = useRef(false);
const lastFetchedSessionId = useRef<string | null>(null);
// Track if we've joined groups to prevent duplicate joins
const hasJoinedGroups = useRef(false);
// Use global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
// Initial data fetch
useEffect(() => {
if (!sessionId) return;
// Update sessionIdRef
sessionIdRef.current = sessionId;
// Reset fetch flag only if navigating to a different session
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
}
// Prevent duplicate fetches in React StrictMode (development double-mounting)
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
fetchSessionDetails();
fetchEvents();
}, [sessionId]);
// Join SignalR groups when connected (for multi-tenancy and cost optimization)
useEffect(() => {
if (!sessionId || !isConnected || !tenantId) return;
if (!hasJoinedGroups.current) {
// Join both tenant group (for newEvents) and session group (for eventStream)
const tenantGroupName = `tenant-${tenantId}`;
const sessionGroupName = `session-${tenantId}-${sessionId}`;
joinGroup(tenantGroupName);
joinGroup(sessionGroupName);
hasJoinedGroups.current = true;
}
return () => {
// Leave groups when component unmounts or sessionId changes
if (hasJoinedGroups.current && tenantId) {
const tenantGroupName = `tenant-${tenantId}`;
const sessionGroupName = `session-${tenantId}-${sessionId}`;
leaveGroup(tenantGroupName);
leaveGroup(sessionGroupName);
hasJoinedGroups.current = false;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isConnected, tenantId]);
// Setup SignalR listener - re-register when connection changes
useEffect(() => {
// Listen for real-time event stream (cost-efficient - no HTTP requests!)
const handleEventStream = (data: { sessionId: string; tenantId: string; events: EnrollmentEvent[]; session: Session }) => {
console.log('Event stream received via SignalR:', data);
if (data.sessionId === sessionIdRef.current && data.events && data.events.length > 0) {
// Add phase names to new events
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
...e,
phaseName: phaseNames[e.phase] || "Unknown"
}));
// Add new events to existing list (deduplication by eventId)
setEvents(prevEvents => {
const existingIds = new Set(prevEvents.map(e => e.eventId));
const newEvents = eventsWithPhaseNames.filter(e => !existingIds.has(e.eventId));
return [...prevEvents, ...newEvents].sort((a, b) => a.sequence - b.sequence);
});
// Update session details directly from SignalR (no HTTP request needed!)
if (data.session) {
setSession(data.session);
}
}
};
// Also listen for summary updates to update session details
const handleNewEvents = (data: { sessionId: string; tenantId: string; eventCount: number; session: Session }) => {
if (data.sessionId === sessionIdRef.current) {
// Update session details directly from SignalR (no HTTP request needed!)
if (data.session) {
setSession(data.session);
}
}
};
on('eventstream', handleEventStream);
on('newevents', handleNewEvents);
return () => {
off('eventstream', handleEventStream);
off('newevents', handleNewEvents);
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [on, off]); // Re-register when SignalR connection changes
const fetchSessionDetails = async () => {
try {
// For now, we'll get session details from the sessions list
const response = await fetch(`${API_BASE_URL}/api/sessions?tenantId=${tenantId}`);
if (response.ok) {
const data = await response.json();
const foundSession = data.sessions?.find((s: Session) => s.sessionId === sessionId);
if (foundSession) {
setSession(foundSession);
}
}
} catch (error) {
console.error("Failed to fetch session details:", error);
}
};
const fetchEvents = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/events?tenantId=${tenantId}`);
if (response.ok) {
const data = await response.json();
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
...e,
phaseName: phaseNames[e.phase] || "Unknown"
}));
setEvents(eventsWithPhaseNames);
}
} catch (error) {
console.error("Failed to fetch events:", error);
} finally {
setLoading(false);
}
};
const markAsFailed = () => {
setShowMarkFailedConfirm(true);
};
const confirmMarkFailed = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed?tenantId=${tenantId}`, {
method: 'POST',
});
if (response.ok) {
setShowMarkFailedConfirm(false);
// Session will be updated via SignalR, but we can also update local state immediately
if (session) {
setSession({
...session,
status: 'Failed'
});
}
} else {
console.error('Failed to mark session as failed');
}
} catch (error) {
console.error('Error marking session as failed:', error);
}
};
const cancelMarkFailed = () => {
setShowMarkFailedConfirm(false);
};
// Group events by phase
const eventsByPhase = events.reduce((acc, event) => {
const phaseName = event.phaseName || "Unknown";
if (!acc[phaseName]) {
acc[phaseName] = [];
}
acc[phaseName].push(event);
return acc;
}, {} as Record<string, EnrollmentEvent[]>);
const phaseOrder = ["PreFlight", "Network", "Identity", "MDM Enrollment", "ESP Device Setup", "App Installation", "ESP User Setup", "Complete", "Failed"];
const orderedPhases = phaseOrder.filter(phase => eventsByPhase[phase]);
// Initialize all phases as expanded on first load
useEffect(() => {
if (orderedPhases.length > 0 && expandedPhases.size === 0) {
setExpandedPhases(new Set(orderedPhases));
}
}, [orderedPhases.length]);
const expandAll = () => {
setExpandedPhases(new Set(orderedPhases));
};
const collapseAll = () => {
setExpandedPhases(new Set());
};
const togglePhase = (phaseName: string) => {
const newExpanded = new Set(expandedPhases);
if (newExpanded.has(phaseName)) {
newExpanded.delete(phaseName);
} else {
newExpanded.add(phaseName);
}
setExpandedPhases(newExpanded);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-600">Loading session details...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 flex items-center justify-between">
<div>
<button
onClick={() => router.push('/')}
className="text-sm text-gray-600 hover:text-gray-900 mb-2 flex items-center"
>
← Back to Dashboard
</button>
<h1 className="text-3xl font-bold text-gray-900">
Session Details
</h1>
</div>
{adminMode && session?.status === 'InProgress' && (
<button
onClick={markAsFailed}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Mark as Failed
</button>
)}
</div>
</header>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Session Info Card */}
{session && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Device Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoItem label="Device" value={session.deviceName || session.serialNumber} />
<InfoItem label="Model" value={`${session.manufacturer} ${session.model}`} />
<InfoItem label="Serial Number" value={session.serialNumber} />
<InfoItem label="Session ID" value={session.sessionId} />
<InfoItem label="Status" value={<StatusBadge status={session.status} />} />
<InfoItem label="Events" value={session.eventCount.toString()} />
<InfoItem label="Duration" value={`${Math.round(session.durationSeconds / 60)} min`} />
</div>
</div>
)}
{/* Phase Timeline */}
{session && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Enrollment Progress</h2>
<PhaseTimeline
currentPhase={session.currentPhase}
completedPhases={session.status === 'Succeeded' ? [7] : []}
/>
</div>
)}
{/* Timeline */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900">Event Timeline</h2>
{orderedPhases.length > 0 && (
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-1 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
className="px-3 py-1 text-sm bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
Collapse All
</button>
</div>
)}
</div>
{orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found for this session.</div>
) : (
<div className="space-y-8">
{orderedPhases.map((phaseName) => (
<PhaseSection
key={phaseName}
phaseName={phaseName}
events={eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
/>
))}
</div>
)}
</div>
</div>
{/* Mark as Failed Confirmation Modal */}
{showMarkFailedConfirm && session && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={cancelMarkFailed}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Mark Session as Failed</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
You are about to manually mark this session as <span className="font-semibold text-red-600">Failed</span>.
</p>
<p className="text-sm text-gray-700 mb-2">
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason "Manually marked as failed by administrator".
</p>
<p className="text-sm text-gray-600">
This action will update the session status and cannot be undone. Do you want to continue?
</p>
</div>
<div className="flex justify-end gap-3">
<button onClick={cancelMarkFailed} className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors">
Cancel
</button>
<button onClick={confirmMarkFailed} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors">
Mark as Failed
</button>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<div className="text-sm font-medium text-gray-500">{label}</div>
<div className="mt-1 text-sm text-gray-900">{value}</div>
</div>
);
}
function PhaseSection({
phaseName,
events,
isExpanded,
onToggle
}: {
phaseName: string;
events: EnrollmentEvent[];
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<div className="border-l-4 border-blue-500 pl-4">
<button
onClick={onToggle}
className="flex items-center justify-between w-full text-left mb-3 group"
>
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
{phaseName} ({events.length} events)
</h3>
<span className="text-gray-400">{isExpanded ? '▼' : '▶'}</span>
</button>
{isExpanded && (
<div className="space-y-3">
{events.map((event, index) => (
<EventRow key={event.eventId || `${event.sessionId}-${event.sequence}`} event={event} />
))}
</div>
)}
</div>
);
}
function EventRow({ event }: { event: EnrollmentEvent }) {
const [showDetails, setShowDetails] = useState(false);
return (
<div className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-mono">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
<SeverityBadge severity={event.severity} />
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
</div>
<p className="mt-1 text-sm text-gray-600">{event.message}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Source: {event.source}</span>
<span>Seq: {event.sequence}</span>
</div>
</div>
{event.data && Object.keys(event.data).length > 0 && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800 ml-4"
>
{showDetails ? 'Hide' : 'Details'}
</button>
)}
</div>
{showDetails && event.data && (
<div className="mt-3 p-3 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(event.data, null, 2)}</pre>
</div>
)}
</div>
);
}
function PhaseTimeline({ currentPhase, completedPhases }: { currentPhase: number; completedPhases: number[] }) {
console.log('[PhaseTimeline] currentPhase:', currentPhase, 'completedPhases:', completedPhases);
const phases = [
{ id: 0, name: "PreFlight", shortName: "Pre-Flight" },
{ id: 1, name: "Network", shortName: "Network" },
{ id: 2, name: "Identity", shortName: "Identity" },
{ id: 3, name: "MDM Enrollment", shortName: "MDM" },
{ id: 4, name: "ESP Device Setup", shortName: "Device" },
{ id: 5, name: "App Installation", shortName: "Apps" },
{ id: 6, name: "ESP User Setup", shortName: "User" },
{ id: 7, name: "Complete", shortName: "Complete" },
];
const getPhaseStatus = (phaseId: number) => {
// If phase is completed, show as completed (green) even if it's the current phase
if (completedPhases.includes(phaseId)) return 'completed';
// If it's the current phase but not completed yet, show as current (blue)
if (phaseId === currentPhase) return 'current';
// If it's before the current phase, show as completed (for phases that were skipped or not explicitly tracked)
if (phaseId < currentPhase) return 'completed';
return 'pending';
};
const getPhaseColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-500 text-white border-green-500';
case 'current': return 'bg-blue-500 text-white border-blue-500 ring-4 ring-blue-200';
case 'pending': return 'bg-gray-200 text-gray-500 border-gray-300';
default: return 'bg-gray-200 text-gray-500 border-gray-300';
}
};
const getConnectorColor = (fromPhase: number) => {
const fromStatus = getPhaseStatus(fromPhase);
const toStatus = getPhaseStatus(fromPhase + 1);
return fromStatus === 'completed' ? 'bg-green-500' : 'bg-gray-300';
};
return (
<div className="w-full overflow-x-auto py-4">
<div className="flex items-center w-full">
{phases.map((phase, index) => {
const status = getPhaseStatus(phase.id);
const nextStatus = index < phases.length - 1 ? getPhaseStatus(phases[index + 1].id) : null;
const showArrow = status === 'current';
return (
<div key={phase.id} className="flex items-center" style={{ flex: index < phases.length - 1 ? '1 1 0%' : '0 0 auto' }}>
<div className="flex flex-col items-center flex-shrink-0">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : phase.id + 1}
</div>
<div className="mt-3 text-xs font-medium text-center text-gray-700 whitespace-nowrap">
{phase.shortName}
</div>
</div>
{index < phases.length - 1 && (
<div className="flex items-center flex-1 mx-3 mb-8">
<div className={`h-1 flex-1 transition-all ${getConnectorColor(phase.id)}`} />
{showArrow && (
<div className="relative" style={{ marginLeft: '-6px', marginRight: '-6px' }}>
<div
className={`w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[12px] ${
getConnectorColor(phase.id) === 'bg-green-500' ? 'border-l-green-500' : 'border-l-gray-300'
}`}
style={{
filter: getConnectorColor(phase.id) === 'bg-green-500'
? 'drop-shadow(0 0 3px rgba(34, 197, 94, 0.5))'
: 'drop-shadow(0 0 3px rgba(209, 213, 219, 0.5))'
}}
/>
</div>
)}
{!showArrow && <div className={`h-1 flex-1 transition-all ${getConnectorColor(phase.id)}`} />}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function SeverityBadge({ severity }: { severity: string }) {
const colors = {
Info: "bg-blue-100 text-blue-800",
Warning: "bg-yellow-100 text-yellow-800",
Error: "bg-red-100 text-red-800",
Critical: "bg-red-200 text-red-900"
};
const color = colors[severity as keyof typeof colors] || colors.Info;
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${color}`}>
{severity}
</span>
);
}
function StatusBadge({ status }: { status: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800", text: "Failed" },
Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
return (
<span className={`px-2 inline-block text-xs leading-5 font-semibold rounded-full ${config.color}`}>
{config.text}
</span>
);
}
@@ -1,4 +1,4 @@
import { EnrollmentEvent } from "../page";
import { EnrollmentEvent } from "@/types";
// Pure helper — groups a flat event list into phase buckets.
// Extracted from the useMemo so it can be called multiple times for WhiteGlove split timelines.
@@ -1,6 +1,6 @@
"use client";
import { TenantAdmin } from "../page";
import { TenantAdmin } from "../types";
interface AdminManagementSectionProps {
admins: TenantAdmin[];
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { DiagnosticsLogPath } from "../page";
import { DiagnosticsLogPath } from "../types";
import { validateDiagnosticsPath } from "@/lib/guardValidation";
import { ValidationIndicator } from "@/components/ValidationIndicator";
@@ -21,75 +21,7 @@ import DataManagementSection from "./components/DataManagementSection";
import OffboardingSection from "./components/OffboardingSection";
import BootstrapSessionsSection, { BootstrapSessionItem } from "./components/BootstrapSessionsSection";
import UnrestrictedModeSection from "./components/UnrestrictedModeSection";
export interface DiagnosticsLogPath {
path: string;
description: string;
isBuiltIn: boolean;
}
export interface TenantConfiguration {
tenantId: string;
lastUpdated: string;
updatedBy: string;
manufacturerWhitelist: string;
modelWhitelist: string;
validateAutopilotDevice: boolean;
validateCorporateIdentifier?: boolean;
allowInsecureAgentRequests?: boolean;
dataRetentionDays: number;
sessionTimeoutHours: number;
customSettings?: string;
// Agent collector settings
enablePerformanceCollector: boolean;
performanceCollectorIntervalSeconds: number;
helloWaitTimeoutSeconds?: number;
// Agent behavior
selfDestructOnComplete?: boolean;
keepLogFile?: boolean;
rebootOnComplete?: boolean;
rebootDelaySeconds?: number;
enableGeoLocation?: boolean;
enableImeMatchLog?: boolean;
logLevel?: string;
// Teams notifications (legacy)
teamsWebhookUrl?: string;
teamsNotifyOnSuccess?: boolean;
teamsNotifyOnFailure?: boolean;
// Webhook notifications (new)
webhookProviderType?: number;
webhookUrl?: string;
webhookNotifyOnSuccess?: boolean;
webhookNotifyOnFailure?: boolean;
// Diagnostics package
diagnosticsBlobSasUrl?: string;
diagnosticsUploadMode?: string;
diagnosticsLogPathsJson?: string;
// Enrollment summary dialog
showEnrollmentSummary?: boolean;
enrollmentSummaryTimeoutSeconds?: number;
enrollmentSummaryBrandingImageUrl?: string;
enrollmentSummaryLaunchRetrySeconds?: number;
// Script output visibility
showScriptOutput?: boolean;
// Agent analyzer settings
enableLocalAdminAnalyzer?: boolean;
localAdminAllowedAccountsJson?: string;
// Bootstrap token
bootstrapTokenEnabled?: boolean;
// Unrestricted mode
unrestrictedMode?: boolean;
}
export interface TenantAdmin {
tenantId: string;
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
role: string | null;
canManageBootstrapTokens: boolean;
}
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath } from "./types";
export default function SettingsPage() {
const router = useRouter();
@@ -0,0 +1,68 @@
export interface DiagnosticsLogPath {
path: string;
description: string;
isBuiltIn: boolean;
}
export interface TenantConfiguration {
tenantId: string;
lastUpdated: string;
updatedBy: string;
manufacturerWhitelist: string;
modelWhitelist: string;
validateAutopilotDevice: boolean;
validateCorporateIdentifier?: boolean;
allowInsecureAgentRequests?: boolean;
dataRetentionDays: number;
sessionTimeoutHours: number;
customSettings?: string;
// Agent collector settings
enablePerformanceCollector: boolean;
performanceCollectorIntervalSeconds: number;
helloWaitTimeoutSeconds?: number;
// Agent behavior
selfDestructOnComplete?: boolean;
keepLogFile?: boolean;
rebootOnComplete?: boolean;
rebootDelaySeconds?: number;
enableGeoLocation?: boolean;
enableImeMatchLog?: boolean;
logLevel?: string;
// Teams notifications (legacy)
teamsWebhookUrl?: string;
teamsNotifyOnSuccess?: boolean;
teamsNotifyOnFailure?: boolean;
// Webhook notifications (new)
webhookProviderType?: number;
webhookUrl?: string;
webhookNotifyOnSuccess?: boolean;
webhookNotifyOnFailure?: boolean;
// Diagnostics package
diagnosticsBlobSasUrl?: string;
diagnosticsUploadMode?: string;
diagnosticsLogPathsJson?: string;
// Enrollment summary dialog
showEnrollmentSummary?: boolean;
enrollmentSummaryTimeoutSeconds?: number;
enrollmentSummaryBrandingImageUrl?: string;
enrollmentSummaryLaunchRetrySeconds?: number;
// Script output visibility
showScriptOutput?: boolean;
// Agent analyzer settings
enableLocalAdminAnalyzer?: boolean;
localAdminAllowedAccountsJson?: string;
// Bootstrap token
bootstrapTokenEnabled?: boolean;
// Unrestricted mode
unrestrictedMode?: boolean;
}
export interface TenantAdmin {
tenantId: string;
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
role: string | null;
canManageBootstrapTokens: boolean;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
export interface EnrollmentEvent {
eventId: string;
sessionId: string;
timestamp: string;
eventType: string;
severity: string;
source: string;
phase: number;
phaseName?: string;
message: string;
sequence: number;
data?: Record<string, any>;
}
export interface RuleResult {
resultId: string;
sessionId: string;
tenantId: string;
ruleId: string;
ruleTitle: string;
severity: string;
category: string;
confidenceScore: number;
explanation: string;
remediation: { title: string; steps: string[] }[];
relatedDocs: { title: string; url: string }[];
matchedConditions: Record<string, any>;
detectedAt: string;
}
@@ -0,0 +1,2 @@
export type { Session } from "./session";
export type { EnrollmentEvent, RuleResult } from "./enrollment";
@@ -0,0 +1,32 @@
export interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
completedAt?: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
failureReason?: string;
enrollmentType?: string; // "v1" | "v2" — absent for sessions before this feature
diagnosticsBlobName?: string;
lastEventAt?: string;
isPreProvisioned?: boolean;
isHybridJoin?: boolean;
isUserDriven?: boolean;
agentVersion?: string;
// OS details
osName?: string;
osBuild?: string;
osDisplayVersion?: string;
osEdition?: string;
osLanguage?: string;
// Geographic location
geoCountry?: string;
geoRegion?: string;
geoCity?: string;
}
+15
View File
@@ -0,0 +1,15 @@
# Lessons Learned
<!-- Updated after every correction from the user. Review at session start. -->
## Rules
<!-- Pattern: What went wrong → What to do instead -->
## Code Style
## Architecture
## Testing
## Common Mistakes