From 897fd02fe8dae029cf401cb0eb0274cda572613f Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 16 Feb 2026 19:59:57 +0100 Subject: [PATCH] Upload Diagnostics package implementation --- .../AutopilotMonitor.Agent.Core.csproj | 2 + .../Configuration/AgentConfiguration.cs | 12 + .../Core/DiagnosticsPackageService.cs | 234 ++++++++++++++++++ .../Monitoring/Core/MonitoringService.cs | 73 +++++- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../Functions/GetAgentConfigFunction.cs | 2 + .../Services/TenantConfigurationService.cs | 4 + .../Models/AgentConfigResponse.cs | 12 + .../Models/TenantConfiguration.cs | 15 ++ .../app/settings/page.tsx | 82 ++++++ 10 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs diff --git a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj index f56c7e30..9f5fae16 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj +++ b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj @@ -16,6 +16,8 @@ + + diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs index 69da3d62..e1d573a4 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs @@ -143,6 +143,18 @@ namespace AutopilotMonitor.Agent.Core.Configuration /// public int AuthFailureTimeoutMinutes { get; set; } = 0; + /// + /// Azure Blob Storage Container SAS URL for diagnostics package upload. + /// The agent uploads directly to the tenant's storage. + /// + public string DiagnosticsBlobSasUrl { get; set; } + + /// + /// When to upload diagnostics packages: "Off", "Always", "OnFailure". + /// Default: "Off" + /// + public string DiagnosticsUploadMode { get; set; } = "Off"; + /// /// Validates the configuration /// diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs new file mode 100644 index 00000000..20bb6e96 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DiagnosticsPackageService.cs @@ -0,0 +1,234 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.Core.Configuration; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Core +{ + /// + /// Creates and uploads a diagnostics ZIP package (agent logs + IME logs + session info) + /// directly to the tenant's Azure Blob Storage container via SAS URL. + /// + public class DiagnosticsPackageService + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly HttpClient _httpClient; + + private static readonly string ImeLogFolder = + Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"); + + private static readonly string AgentLogFolder = + Environment.ExpandEnvironmentVariables(Constants.LogDirectory); + + public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger) + { + _configuration = configuration; + _logger = logger; + _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + } + + /// + /// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container. + /// Returns true if the upload succeeded, false if skipped or failed. + /// This method is non-fatal: all exceptions are caught and logged. + /// + public async Task CreateAndUploadAsync(bool enrollmentSucceeded) + { + try + { + // Check if upload is needed based on configuration + var mode = _configuration.DiagnosticsUploadMode ?? "Off"; + if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug("Diagnostics upload disabled (mode=Off)"); + return false; + } + + if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl)) + { + _logger.Debug("Diagnostics upload skipped: no Blob Storage SAS URL configured"); + return false; + } + + if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) + { + _logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure"); + return false; + } + + _logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded})..."); + + // Build ZIP in memory + var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}.zip"; + + byte[] zipBytes; + using (var ms = new MemoryStream()) + { + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + // 1. sessioninfo.txt + AddSessionInfo(archive, enrollmentSucceeded); + + // 2. Agent logs + AddLogFiles(archive, AgentLogFolder, "AgentLogs", "*.log"); + + // 3. IME logs + AddLogFiles(archive, ImeLogFolder, "ImeLogs", "IntuneManagementExtension*.log"); + AddLogFiles(archive, ImeLogFolder, "ImeLogs", "AppWorkload*.log"); + } + + zipBytes = ms.ToArray(); + } + + _logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)"); + + // Upload to Blob Storage + var uploaded = await UploadToBlobStorageAsync(zipFileName, zipBytes); + if (uploaded) + { + _logger.Info($"Diagnostics package uploaded successfully: {zipFileName}"); + } + + return uploaded; + } + catch (Exception ex) + { + _logger.Warning($"Diagnostics package creation/upload failed (non-fatal): {ex.Message}"); + return false; + } + } + + private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded) + { + var sb = new StringBuilder(); + sb.AppendLine($"Session ID: {_configuration.SessionId}"); + sb.AppendLine($"Tenant ID: {_configuration.TenantId}"); + sb.AppendLine($"Device Name: {Environment.MachineName}"); + sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}"); + sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}"); + + // Hardware info via existing DeviceInfoProvider (WMI) + sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}"); + sb.AppendLine($"Model: {DeviceInfoProvider.GetModel()}"); + sb.AppendLine($"Serial Number: {DeviceInfoProvider.GetSerialNumber()}"); + + var entry = archive.CreateEntry("sessioninfo.txt"); + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); + writer.Write(sb.ToString()); + } + + private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern) + { + if (!Directory.Exists(sourceFolder)) + { + _logger.Debug($"Log folder not found, skipping: {sourceFolder}"); + return; + } + + try + { + var files = Directory.GetFiles(sourceFolder, searchPattern); + foreach (var file in files) + { + try + { + var fileName = Path.GetFileName(file); + var entryName = $"{zipFolder}/{fileName}"; + + // Read with FileShare.ReadWrite to avoid locking conflicts with active log writers + byte[] content; + using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new MemoryStream()) + { + fs.CopyTo(reader); + content = reader.ToArray(); + } + + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + entryStream.Write(content, 0, content.Length); + + _logger.Debug($"Added to diagnostics package: {entryName} ({content.Length / 1024} KB)"); + } + catch (Exception ex) + { + _logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}"); + } + } + + /// + /// Uploads the ZIP bytes to the configured Azure Blob Storage container via PUT with SAS token. + /// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=... + /// Blob URL is constructed by inserting the blob name before the query string. + /// + private async Task UploadToBlobStorageAsync(string blobName, byte[] data) + { + var containerSasUrl = _configuration.DiagnosticsBlobSasUrl; + + // Build blob URL: split at '?' to insert blob name + var questionMarkIndex = containerSasUrl.IndexOf('?'); + string blobUrl; + if (questionMarkIndex >= 0) + { + var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/'); + var queryString = containerSasUrl.Substring(questionMarkIndex); + blobUrl = $"{basePath}/{blobName}{queryString}"; + } + else + { + // No query string (unlikely for SAS but handle gracefully) + blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}"; + } + + const int maxRetries = 3; + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + _logger.Info($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)..."); + + using var content = new ByteArrayContent(data); + content.Headers.Add("x-ms-blob-type", "BlockBlob"); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip"); + + var response = await _httpClient.PutAsync(blobUrl, content); + + if (response.IsSuccessStatusCode) + { + return true; + } + + var responseBody = await response.Content.ReadAsStringAsync(); + _logger.Warning($"Blob upload attempt {attempt} failed: HTTP {(int)response.StatusCode} - {responseBody}"); + } + catch (Exception ex) + { + _logger.Warning($"Blob upload attempt {attempt} failed: {ex.Message}"); + } + + if (attempt < maxRetries) + { + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2s, 4s, 8s + _logger.Info($"Retrying blob upload in {delay.TotalSeconds}s..."); + await Task.Delay(delay); + } + } + + _logger.Warning($"Diagnostics package upload failed after {maxRetries} attempts"); + return false; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 838d3b3d..1b9efd4f 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -49,6 +49,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Cleanup/self-destruct private readonly CleanupService _cleanupService; + // Diagnostics package upload + private readonly DiagnosticsPackageService _diagnosticsService; + // Auth failure circuit breaker private int _consecutiveAuthFailures = 0; private DateTime? _firstAuthFailureTime = null; @@ -67,6 +70,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _spool = new EventSpool(_configuration.SpoolDirectory); _apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger); _cleanupService = new CleanupService(_configuration, _logger); + _diagnosticsService = new DiagnosticsPackageService(_configuration, _logger); // Subscribe to spool events for batched upload when new events arrive _spool.EventsAvailable += OnEventsAvailable; @@ -251,6 +255,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _configuration.RebootOnComplete = config.RebootOnComplete; _configuration.RebootDelaySeconds = config.RebootDelaySeconds; _configuration.MaxBatchSize = config.MaxBatchSize; + _configuration.DiagnosticsBlobSasUrl = config.DiagnosticsBlobSasUrl; + _configuration.DiagnosticsUploadMode = config.DiagnosticsUploadMode; // Apply log level from remote config if (Enum.TryParse(config.LogLevel, ignoreCase: true, out var remoteLogLevel)) @@ -529,6 +535,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Check for enrollment completion events if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed") { + var enrollmentSucceeded = evt.EventType == "enrollment_complete"; _logger.Info($"Enrollment completion detected: {evt.EventType}"); // Delete session ID so a new session will be created on next enrollment @@ -559,9 +566,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Trigger self-destruct or reboot if configured if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete) { - Task.Run(() => HandleEnrollmentComplete()); + Task.Run(() => HandleEnrollmentComplete(enrollmentSucceeded)); return; // Don't continue with normal event processing } + + // No self-destruct/reboot — still upload diagnostics if configured + Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded)); } // Immediate upload for: @@ -724,10 +734,66 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core } } + /// + /// Uploads diagnostics package with timeline events (start + result). + /// Emits events, uploads them, then performs the actual diagnostics upload. + /// + private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded) + { + var mode = _configuration.DiagnosticsUploadMode ?? "Off"; + if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) + return; + if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl)) + return; + if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) + return; + + // Emit "collecting" event + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + Timestamp = DateTime.UtcNow, + EventType = "diagnostics_collecting", + Severity = EventSeverity.Info, + Source = "MonitoringService", + Phase = EnrollmentPhase.Complete, + Message = $"Collecting diagnostics package (mode={mode})", + Data = new Dictionary + { + { "uploadMode", mode } + } + }); + await UploadEventsAsync(); + + // Perform the actual ZIP + upload + var success = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded); + + // Emit result event + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + Timestamp = DateTime.UtcNow, + EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed", + Severity = success ? EventSeverity.Info : EventSeverity.Warning, + Source = "MonitoringService", + Phase = EnrollmentPhase.Complete, + Message = success + ? "Diagnostics package uploaded successfully" + : "Diagnostics package upload failed", + Data = new Dictionary + { + { "uploadMode", mode } + } + }); + await UploadEventsAsync(); + } + /// /// Handles enrollment completion and triggers self-destruct sequence /// - private async Task HandleEnrollmentComplete() + private async Task HandleEnrollmentComplete(bool enrollmentSucceeded) { try { @@ -745,6 +811,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Give a moment for final upload to complete await Task.Delay(2000); + // Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot) + await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded); + // Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only if (_configuration.SelfDestructOnComplete) { diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index e522732c..d81cc071 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -38 +42 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs index 92c7b36a..b9659b65 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs @@ -102,6 +102,8 @@ namespace AutopilotMonitor.Functions.Functions RebootOnComplete = tenantConfig.RebootOnComplete ?? false, RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10, MaxBatchSize = tenantConfig.MaxBatchSize ?? 100, + DiagnosticsBlobSasUrl = tenantConfig.DiagnosticsBlobSasUrl, + DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off", Collectors = collectors, GatherRules = gatherRules, ImeLogPatterns = imeLogPatterns diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 07e9825a..d57ad8e8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -186,6 +186,8 @@ namespace AutopilotMonitor.Functions.Services { "EnableImeMatchLog", config.EnableImeMatchLog }, { "LogLevel", config.LogLevel }, { "MaxBatchSize", config.MaxBatchSize }, + { "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl }, + { "DiagnosticsUploadMode", config.DiagnosticsUploadMode }, { "TeamsWebhookUrl", config.TeamsWebhookUrl }, { "TeamsNotifyOnSuccess", config.TeamsNotifyOnSuccess }, { "TeamsNotifyOnFailure", config.TeamsNotifyOnFailure } @@ -226,6 +228,8 @@ namespace AutopilotMonitor.Functions.Services EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"), LogLevel = entity.GetString("LogLevel"), MaxBatchSize = entity.GetInt32("MaxBatchSize"), + DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"), + DiagnosticsUploadMode = entity.GetString("DiagnosticsUploadMode") ?? "Off", TeamsWebhookUrl = entity.GetString("TeamsWebhookUrl"), TeamsNotifyOnSuccess = entity.GetBoolean("TeamsNotifyOnSuccess") ?? true, TeamsNotifyOnFailure = entity.GetBoolean("TeamsNotifyOnFailure") ?? true diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs index 3249db61..2ed20fc1 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs @@ -91,6 +91,18 @@ namespace AutopilotMonitor.Shared.Models /// Default: 100 /// public int MaxBatchSize { get; set; } = 100; + + /// + /// Azure Blob Storage Container SAS URL for diagnostics package upload. + /// The agent uploads directly to the tenant's storage — data never passes through the backend. + /// + public string DiagnosticsBlobSasUrl { get; set; } + + /// + /// When to upload diagnostics packages: "Off", "Always", "OnFailure". + /// Default: "Off" + /// + public string DiagnosticsUploadMode { get; set; } = "Off"; } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs index 0bd371f0..ef90dcb5 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs @@ -203,6 +203,21 @@ namespace AutopilotMonitor.Shared.Models /// public int? MaxBatchSize { get; set; } + // ===== DIAGNOSTICS SETTINGS ===== + + /// + /// Azure Blob Storage Container SAS URL for diagnostics package upload. + /// Each tenant provides their own container — data stays in the customer's storage. + /// If null or empty, diagnostics upload is disabled. + /// + public string DiagnosticsBlobSasUrl { get; set; } + + /// + /// When to upload diagnostics packages: "Off", "Always", "OnFailure". + /// Default: "Off" + /// + public string DiagnosticsUploadMode { get; set; } = "Off"; + // ===== TEAMS NOTIFICATIONS ===== /// diff --git a/src/Web/autopilot-monitor-web/app/settings/page.tsx b/src/Web/autopilot-monitor-web/app/settings/page.tsx index 3ea974f3..3048f71e 100644 --- a/src/Web/autopilot-monitor-web/app/settings/page.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/page.tsx @@ -33,6 +33,9 @@ interface TenantConfiguration { teamsWebhookUrl?: string; teamsNotifyOnSuccess?: boolean; teamsNotifyOnFailure?: boolean; + // Diagnostics package + diagnosticsBlobSasUrl?: string; + diagnosticsUploadMode?: string; } interface TenantAdmin { @@ -93,6 +96,10 @@ export default function SettingsPage() { const [teamsNotifyOnSuccess, setTeamsNotifyOnSuccess] = useState(true); const [teamsNotifyOnFailure, setTeamsNotifyOnFailure] = useState(true); + // Diagnostics package state + const [diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl] = useState(""); + const [diagnosticsUploadMode, setDiagnosticsUploadMode] = useState("Off"); + // Fetch configuration useEffect(() => { if (!tenantId) return; @@ -139,6 +146,8 @@ export default function SettingsPage() { setTeamsWebhookUrl(data.teamsWebhookUrl ?? ""); setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true); setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true); + setDiagnosticsBlobSasUrl(data.diagnosticsBlobSasUrl ?? ""); + setDiagnosticsUploadMode(data.diagnosticsUploadMode ?? "Off"); } catch (err) { console.error("Error fetching configuration:", err); setError(err instanceof Error ? err.message : "Failed to load configuration"); @@ -216,6 +225,8 @@ export default function SettingsPage() { teamsWebhookUrl: teamsWebhookUrl || undefined, teamsNotifyOnSuccess, teamsNotifyOnFailure, + diagnosticsBlobSasUrl: diagnosticsBlobSasUrl || undefined, + diagnosticsUploadMode, }; const token = await getAccessToken(); @@ -386,6 +397,8 @@ export default function SettingsPage() { setTeamsWebhookUrl(config.teamsWebhookUrl ?? ""); setTeamsNotifyOnSuccess(config.teamsNotifyOnSuccess ?? true); setTeamsNotifyOnFailure(config.teamsNotifyOnFailure ?? true); + setDiagnosticsBlobSasUrl(config.diagnosticsBlobSasUrl ?? ""); + setDiagnosticsUploadMode(config.diagnosticsUploadMode ?? "Off"); setSuccessMessage(null); setError(null); }; @@ -1165,6 +1178,75 @@ export default function SettingsPage() { + {/* Diagnostics Package */} +
+
+
+ + + +
+

Diagnostics Package

+

Upload agent and IME logs as a ZIP package to your Azure Blob Storage after enrollment.

+
+
+
+
+ + {/* Info */} +
+

+ The agent uploads diagnostics (agent logs + IME logs) directly to your Blob Storage container. Data never passes through our backend. +

+
+ + {/* Blob Storage SAS URL */} +
+ +
+ + {/* Upload Mode */} +
+
+
+

Upload Mode

+

Choose when diagnostics packages are uploaded

+
+ +
+
+ +
+
+ {/* Serial Number Validation */}