From 089c0123e667c1bfb5920b5e7d9d719ce3c00b5b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:02:55 +0200 Subject: [PATCH] feat(reports): add Submit Logs page for session-less diag-files reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/ .json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from /settings/tenant/support without needing a session context — no more OneDrive round-trips for ad-hoc client-log requests. Backend - New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table + session-reports container, distinguished by ReportType="diagFiles". - ReportType discriminator added to SessionReportMetadata + Store/Map (legacy rows default to "session"). - Telegram + Global-Admin bell notifications mirror the session-report flow. Frontend - New "Submit Logs" tenant settings tab with comment / email / log+state files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate. - Admin SessionReportsSection now shows a Type column (Session / Diag Files) and hides Session ID in the detail modal for diag-files rows. Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc --noEmit clean, 259 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../TableNotificationRepository.cs | 8 +- .../Reports/SubmitDiagFilesReportFunction.cs | 141 ++++++++ .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../Services/SessionReportService.cs | 98 +++++- .../Services/TelegramNotificationService.cs | 36 ++ .../Models/ReportApiModels.cs | 46 ++- .../components/SessionReportsSection.tsx | 39 ++- .../app/settings/tenant/[section]/page.tsx | 2 + .../tenant/sections/SectionSubmitLogs.tsx | 329 ++++++++++++++++++ .../app/settings/tenant/tenantNavSections.ts | 1 + src/Web/autopilot-monitor-web/lib/api.ts | 5 + 13 files changed, 699 insertions(+), 11 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs create mode 100644 src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionSubmitLogs.tsx diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index aa104491..231a7d57 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -637 +638 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 9c113c6f..4b809f8d 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1275 +1276 diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs index 6423d621..b7ae230b 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs @@ -167,7 +167,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage ["Email"] = metadata.Email ?? string.Empty, ["BlobName"] = metadata.BlobName ?? string.Empty, ["SubmittedBy"] = metadata.SubmittedBy ?? string.Empty, - ["SubmittedAt"] = metadata.SubmittedAt + ["SubmittedAt"] = metadata.SubmittedAt, + ["ReportType"] = string.IsNullOrEmpty(metadata.ReportType) ? ReportTypes.Session : metadata.ReportType }; await _reportsTableClient.UpsertEntityAsync(entity); @@ -309,6 +310,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage private static SessionReportMetadata MapToSessionReportMetadata(TableEntity entity) { + // Legacy rows written before the ReportType column existed default to "session". + var reportType = entity.GetString("ReportType"); return new SessionReportMetadata { ReportId = entity.GetString("ReportId") ?? string.Empty, @@ -319,7 +322,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage BlobName = entity.GetString("BlobName") ?? string.Empty, SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty, SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue, - AdminNote = entity.GetString("AdminNote") ?? string.Empty + AdminNote = entity.GetString("AdminNote") ?? string.Empty, + ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs new file mode 100644 index 00000000..27588ea3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs @@ -0,0 +1,141 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Reports +{ + /// + /// Tenant-admin entry point for submitting diagnostic files (logs, JSON state files, + /// screenshots) without a session context. Lands in the same SessionReports table + + /// session-reports blob container as session reports, distinguished by + /// = "diagFiles". + /// + public class SubmitDiagFilesReportFunction + { + private readonly ILogger _logger; + private readonly SessionReportService _sessionReportService; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly TelegramNotificationService _telegramNotificationService; + private readonly GlobalNotificationService _globalNotificationService; + + public SubmitDiagFilesReportFunction( + ILogger logger, + SessionReportService sessionReportService, + IMaintenanceRepository maintenanceRepo, + TelegramNotificationService telegramNotificationService, + GlobalNotificationService globalNotificationService) + { + _logger = logger; + _sessionReportService = sessionReportService; + _maintenanceRepo = maintenanceRepo; + _telegramNotificationService = telegramNotificationService; + _globalNotificationService = globalNotificationService; + } + + [Function("SubmitDiagFilesReport")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "diag-files-reports")] HttpRequestData req) + { + _logger.LogInformation("SubmitDiagFilesReport processing request"); + + try + { + // Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware + var requestCtx = req.GetRequestContext(); + var tenantId = requestCtx.TenantId; + var userIdentifier = requestCtx.UserPrincipalName; + + // Body size limit (20 MB) — same as SubmitSessionReport. Diag-files payloads + // are typically smaller (no events.csv/timeline.txt synthesis), but log + // bundles + screenshots still benefit from the same upper bound. + if (req.Headers.TryGetValues("Content-Length", out var clValues) + && long.TryParse(clValues.FirstOrDefault(), out var contentLength) + && contentLength > 20_971_520) + { + var tooLarge = req.CreateResponse(HttpStatusCode.BadRequest); + await tooLarge.WriteAsJsonAsync(new { success = false, message = "Request body too large" }); + return tooLarge; + } + + var request = await req.ReadFromJsonAsync(); + if (request == null) + { + var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequestResponse.WriteAsJsonAsync(new + { + success = false, + message = "Invalid request body." + }); + return badRequestResponse; + } + + if (string.IsNullOrEmpty(request.TenantId)) + { + request.TenantId = tenantId; + } + + var metadata = await _sessionReportService.SubmitDiagFilesReportAsync(request, userIdentifier); + + // Audit log — skip for Global Admins (their actions are not tenant-scoped) + if (!requestCtx.IsGlobalAdmin) + { + await _maintenanceRepo.LogAuditEntryAsync( + request.TenantId, + "CREATE", + "DiagFilesReport", + metadata.ReportId, + userIdentifier, + new Dictionary + { + { "Action", "SubmitDiagFilesReport" }, + { "BlobName", metadata.BlobName }, + { "HasComment", (!string.IsNullOrEmpty(request.Comment)).ToString() }, + { "HasEmail", (!string.IsNullOrEmpty(request.Email)).ToString() }, + { "HasScreenshot", (!string.IsNullOrEmpty(request.ScreenshotBase64)).ToString() }, + { "HasAgentLog", (!string.IsNullOrEmpty(request.AgentLogBase64)).ToString() } + } + ); + } + + // Telegram notification — best effort + _ = _telegramNotificationService.SendDiagFilesReportAsync( + request.TenantId, userIdentifier, metadata.ReportId, request.Comment ?? string.Empty); + + // Persistent in-app notification for Global Admins — best effort + _ = _globalNotificationService.CreateNotificationAsync( + "diag_files_report", + "New Diag Files Report", + $"{userIdentifier} (Tenant: {request.TenantId})"); + + _logger.LogInformation("Diag-files report submitted: ReportId={ReportId}, By={User}", + metadata.ReportId, userIdentifier); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new SubmitSessionReportResponse + { + Success = true, + Message = "Diag files report submitted successfully.", + ReportId = metadata.ReportId + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error submitting diag-files report"); + + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new + { + success = false, + message = "Internal server error" + }); + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index c28f0eea..cef661c2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -178,6 +178,7 @@ public static class EndpointAccessPolicyCatalog new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA), new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrGA), new("POST", "sessions/{sessionId}/report", EndpointPolicy.TenantAdminOrGA), + new("POST", "diag-files-reports", EndpointPolicy.TenantAdminOrGA), new("DELETE", "sessions/{sessionId}", EndpointPolicy.TenantAdminOrGA), new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs index 61ae2274..46ecc011 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs @@ -148,7 +148,103 @@ namespace AutopilotMonitor.Functions.Services Email = request.Email, BlobName = blobName, SubmittedBy = submittedBy, - SubmittedAt = now + SubmittedAt = now, + ReportType = ReportTypes.Session + }; + + await _notificationRepo.StoreSessionReportMetadataAsync(metadata); + + return metadata; + } + + /// + /// Submits a diagnostic-files report (no session context). + /// Persists into the same SessionReports table + session-reports container, but the + /// resulting ZIP only contains user-attached files plus a thin metadata header — + /// no events.csv / timeline.txt / ruleresults.csv synthesis. + /// + public async Task SubmitDiagFilesReportAsync( + SubmitDiagFilesReportRequest request, + string submittedBy) + { + var reportId = Guid.NewGuid().ToString("N")[..12]; + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip"; + + using var zipStream = new MemoryStream(); + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddJsonEntry(archive, "report-metadata.json", new + { + reportId, + reportType = ReportTypes.DiagFiles, + request.TenantId, + request.Comment, + request.Email, + submittedBy, + submittedAt = DateTime.UtcNow.ToString("O") + }); + + if (!string.IsNullOrEmpty(request.ScreenshotBase64)) + { + try + { + var screenshotBytes = Convert.FromBase64String(request.ScreenshotBase64); + var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png"); + if (string.IsNullOrEmpty(ext)) ext = ".png"; + var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal); + using var entryStream = entry.Open(); + await entryStream.WriteAsync(screenshotBytes); + } + catch (FormatException ex) + { + _logger.LogWarning(ex, "Invalid base64 screenshot data in diag-files report {ReportId}", reportId); + } + } + + if (!string.IsNullOrEmpty(request.AgentLogBase64)) + { + try + { + var logBytes = Convert.FromBase64String(request.AgentLogBase64); + var logFileName = string.IsNullOrEmpty(request.AgentLogFileName) + ? "diag-files.bin" + : request.AgentLogFileName; + var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + await entryStream.WriteAsync(logBytes); + } + catch (FormatException ex) + { + _logger.LogWarning(ex, "Invalid base64 log payload in diag-files report {ReportId}", reportId); + } + } + } + + zipStream.Position = 0; + var containerClient = _blobStorage.GetContainerClient(ContainerName); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient(blobName); + await blobClient.UploadAsync(zipStream, new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" } + }); + + _logger.LogInformation( + "Diag-files report uploaded: ReportId={ReportId}, BlobName={BlobName}, Tenant={TenantId}", + reportId, blobName, request.TenantId); + + var metadata = new SessionReportMetadata + { + ReportId = reportId, + TenantId = request.TenantId, + SessionId = string.Empty, + Comment = request.Comment, + Email = request.Email, + BlobName = blobName, + SubmittedBy = submittedBy, + SubmittedAt = DateTime.UtcNow, + ReportType = ReportTypes.DiagFiles }; await _notificationRepo.StoreSessionReportMetadataAsync(metadata); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs index 85942beb..598c98ee 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs @@ -105,6 +105,42 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Sends a Telegram notification when a tenant admin submits a diag-files-only report + /// (no session context). Best-effort — silently no-ops if the webhook URL is not configured. + /// + public async Task SendDiagFilesReportAsync(string tenantId, string submittedBy, string reportId, string comment) + { + try + { + var webhookUrl = await GetWebhookUrlAsync(); + if (string.IsNullOrWhiteSpace(webhookUrl)) + { + _logger.LogDebug("Telegram webhook URL not configured — skipping diag-files report notification"); + return; + } + + var commentLine = string.IsNullOrWhiteSpace(comment) ? "" : $"\nComment: {comment}"; + var payload = new + { + chat_id = "-1003632442830", + text = $"New Diag Files Report submitted!\nTenantID: {tenantId}\nBy: {submittedBy}\nReportID: {reportId}{commentLine}" + }; + + var json = JsonConvert.SerializeObject(payload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync(webhookUrl, content); + + if (response.IsSuccessStatusCode) + _logger.LogInformation( + "Telegram diag-files report notification sent for report {ReportId}", reportId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send Telegram diag-files report notification for {ReportId}", reportId); + } + } + /// /// Posts content to a webhook URL with a single retry on transient failures (429, 5xx, network errors). /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs index 4d89582f..ec9b9175 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs @@ -2,6 +2,20 @@ using System; namespace AutopilotMonitor.Shared.Models { + /// + /// Discriminator values for the ReportType field on . + /// All rows live in the same SessionReports table and the same session-reports blob + /// container regardless of type — only the wire model and ZIP layout differ. + /// + public static class ReportTypes + { + /// Submitted from the session detail page; ZIP carries session/events/timeline exports. + public const string Session = "session"; + + /// Submitted from /settings/tenant/support; ZIP carries only user-attached files. + public const string DiagFiles = "diagFiles"; + } + /// /// 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. @@ -38,6 +52,30 @@ namespace AutopilotMonitor.Shared.Models public string AgentLogFileName { get; set; } = default!; } + /// + /// Request to submit diagnostic files for analysis without a session context. + /// Used from /settings/tenant/support when an admin needs to ship logs/state files + /// to the Autopilot Monitor team without binding them to a specific enrollment session. + /// + public class SubmitDiagFilesReportRequest + { + public string TenantId { get; set; } = default!; + public string Comment { get; set; } = default!; + public string Email { get; set; } = default!; + + /// Base64-encoded screenshot image (optional) + public string ScreenshotBase64 { get; set; } = default!; + + /// Original screenshot file name for extension detection + public string ScreenshotFileName { get; set; } = default!; + + /// Base64-encoded log/state payload (single file or zip of many; max ~5 MB enforced client-side) + public string AgentLogBase64 { get; set; } = default!; + + /// Original file name (e.g. "agent.log", "state.json", "diag-files.zip") + public string AgentLogFileName { get; set; } = default!; + } + /// /// Response from session report submission /// @@ -49,7 +87,10 @@ namespace AutopilotMonitor.Shared.Models } /// - /// Session report metadata for the admin-config reports table + /// Session report metadata for the admin-config reports table. + /// Both Session-context reports and Diag-Files-only reports share this row schema — + /// the discriminator distinguishes the two, and + /// is empty for diag-files submissions. /// public class SessionReportMetadata { @@ -62,5 +103,8 @@ namespace AutopilotMonitor.Shared.Models public string SubmittedBy { get; set; } = default!; public DateTime SubmittedAt { get; set; } public string AdminNote { get; set; } = default!; + + /// One of . Defaults to "session" so legacy rows map cleanly. + public string ReportType { get; set; } = ReportTypes.Session; } } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx index c8f77269..57ddfaed 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx @@ -19,6 +19,21 @@ interface SessionReport { submittedBy: string; submittedAt: string; adminNote?: string; + /** "session" (legacy default) or "diagFiles" (no session context) */ + reportType?: "session" | "diagFiles"; +} + +function ReportTypeBadge({ type }: { type?: string }) { + const isDiagFiles = type === "diagFiles"; + const label = isDiagFiles ? "Diag Files" : "Session"; + const cls = isDiagFiles + ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300" + : "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"; + return ( + + {label} + + ); } interface SessionReportsSectionProps { @@ -304,6 +319,7 @@ export function SessionReportsSection({ Date + Type Session Tenant Submitted By @@ -321,8 +337,13 @@ export function SessionReportsSection({ {new Date(r.submittedAt).toLocaleString()} + + + - {r.sessionId.length > 8 ? `${r.sessionId.slice(0, 8)}...` : r.sessionId} + {r.reportType === "diagFiles" + ? — + : r.sessionId.length > 8 ? `${r.sessionId.slice(0, 8)}...` : r.sessionId} {r.tenantId.length > 8 ? `${r.tenantId.slice(0, 8)}...` : r.tenantId} @@ -396,12 +417,20 @@ export function SessionReportsSection({
-
Session ID
-
- {selectedReport.sessionId} - +
Type
+
+
+ {selectedReport.reportType !== "diagFiles" && selectedReport.sessionId && ( +
+
Session ID
+
+ {selectedReport.sessionId} + +
+
+ )}
Tenant ID
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/page.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/page.tsx index 7e924f15..94fd52a8 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/page.tsx @@ -9,6 +9,7 @@ import { SectionNotifications } from "../sections/SectionNotifications"; import { SectionAccessManagement } from "../sections/SectionAccessManagement"; import { SectionBootstrapSessions } from "../sections/SectionBootstrapSessions"; import { SectionSlaTargets } from "../sections/SectionSlaTargets"; +import { SectionSubmitLogs } from "../sections/SectionSubmitLogs"; const SECTION_COMPONENTS: Record = { "autopilot": SectionAutopilotValidation, @@ -17,6 +18,7 @@ const SECTION_COMPONENTS: Record = { "sla-targets": SectionSlaTargets, "access-management": SectionAccessManagement, "bootstrap-sessions": SectionBootstrapSessions, + "support": SectionSubmitLogs, }; export default function TenantSectionPage() { diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionSubmitLogs.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionSubmitLogs.tsx new file mode 100644 index 00000000..c5b889f6 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionSubmitLogs.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useRef, useState } from "react"; +import { zipSync } from "fflate"; +import { useAuth } from "../../../../contexts/AuthContext"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { api } from "@/lib/api"; +import { trackEvent } from "@/lib/appInsights"; + +const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB +const LOG_ACCEPT = ".log,.txt,.zip,.json,.jsonl,.ndjson"; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +async function fileToBase64(file: File): Promise { + const buffer = await file.arrayBuffer(); + return btoa( + new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), ""), + ); +} + +async function bundleFiles(files: File[], zipName: string): Promise<{ base64: string; fileName: string } | null> { + if (files.length === 0) return null; + if (files.length === 1) { + return { base64: await fileToBase64(files[0]), fileName: files[0].name }; + } + const entries: Record = {}; + for (const f of files) { + entries[f.name] = new Uint8Array(await f.arrayBuffer()); + } + const zipped = zipSync(entries); + const base64 = btoa(zipped.reduce((data, byte) => data + String.fromCharCode(byte), "")); + return { base64, fileName: zipName }; +} + +export function SectionSubmitLogs() { + const { user, getAccessToken } = useAuth(); + + const [comment, setComment] = useState(""); + const [email, setEmail] = useState(""); + const [logFiles, setLogFiles] = useState([]); + const [screenshotFiles, setScreenshotFiles] = useState([]); + const [logError, setLogError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState<"success" | "error" | null>(null); + const [submitErrorMessage, setSubmitErrorMessage] = useState(null); + + const logInputRef = useRef(null); + const screenshotInputRef = useRef(null); + + const addLogs = (newFiles: File[]) => { + setLogError(null); + const merged = [...logFiles]; + for (const f of newFiles) { + if (!merged.some(existing => existing.name === f.name && existing.size === f.size)) { + merged.push(f); + } + } + const totalSize = merged.reduce((sum, f) => sum + f.size, 0); + if (totalSize > MAX_LOG_SIZE) { + setLogError(`Total size (${formatFileSize(totalSize)}) exceeds 5 MB limit. Remove some files or add smaller ones.`); + return; + } + setLogFiles(merged); + if (logInputRef.current) logInputRef.current.value = ""; + }; + + const removeLog = (index: number) => { + setLogError(null); + setLogFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const addScreenshots = (newFiles: File[]) => { + const merged = [...screenshotFiles]; + for (const f of newFiles) { + if (!merged.some(existing => existing.name === f.name && existing.size === f.size)) { + merged.push(f); + } + } + setScreenshotFiles(merged); + if (screenshotInputRef.current) screenshotInputRef.current.value = ""; + }; + + const removeScreenshot = (index: number) => { + setScreenshotFiles(prev => prev.filter((_, i) => i !== index)); + }; + + const handleSubmit = async () => { + if (!user?.tenantId) { + setSubmitResult("error"); + setSubmitErrorMessage("Tenant context unavailable. Please reload the page."); + return; + } + setSubmitting(true); + setSubmitResult(null); + setSubmitErrorMessage(null); + try { + const logBundle = await bundleFiles(logFiles, "diag-files.zip"); + const screenshotBundle = await bundleFiles(screenshotFiles, "screenshots.zip"); + + const response = await authenticatedFetch( + api.diagFilesReports.submit(), + getAccessToken, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tenantId: user.tenantId, + comment, + email, + screenshotBase64: screenshotBundle?.base64 ?? null, + screenshotFileName: screenshotBundle?.fileName ?? null, + agentLogBase64: logBundle?.base64 ?? null, + agentLogFileName: logBundle?.fileName ?? null, + }), + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.message || `Failed to submit (${response.status}).`); + } + + trackEvent("diag_files_report_submitted"); + setSubmitResult("success"); + setComment(""); + setEmail(""); + setLogFiles([]); + setScreenshotFiles([]); + setLogError(null); + } catch (err: unknown) { + if (err instanceof TokenExpiredError) { + setSubmitErrorMessage("Session expired. Please reload the page and try again."); + } else { + setSubmitErrorMessage(err instanceof Error ? err.message : "Failed to submit report."); + } + setSubmitResult("error"); + } finally { + setSubmitting(false); + } + }; + + const logTotalSize = logFiles.reduce((sum, f) => sum + f.size, 0); + const hasAnyContent = comment.trim().length > 0 || logFiles.length > 0 || screenshotFiles.length > 0; + + return ( +
+
+
+ + + +
+
+

Submit Logs to Support

+ + Private Preview + +
+
+ +

+ Send log files, JSON state snapshots, or screenshots to the Autopilot Monitor team for + analysis — no need to share via OneDrive. Submissions are stored in the same backend + pipeline as Session Reports and visible only to Global Admins. +

+ + {submitResult === "success" && ( +
+ + + +
+

Report submitted successfully

+

+ The diagnostic files have been sent for analysis by the Autopilot Monitor team. +

+
+
+ )} + + {submitResult === "error" && submitErrorMessage && ( +
+ + + +
+

Failed to submit report

+

{submitErrorMessage}

+
+
+ )} + + {/* Comment */} +
+ +