mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
restructured source code for better maintenance and added ai files
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+108
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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("'", "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+100
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
+302
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
-1094
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
}
|
||||
-201
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { RuleResult } from "../page";
|
||||
import { RuleResult } from "@/types";
|
||||
|
||||
interface AnalysisResultsSectionProps {
|
||||
analysisResults: RuleResult[];
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user