mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat: richer, self-describing session report content
The report ZIP now captures everything an operator needs even after the session is gone, and states its own completeness: - session.csv: append the 15 newer Sessions fields that had drifted out of the export (failureSource, reconcileReason, adminMarkedAction, validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile, connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt, failureSnapshotJson) - completeness signal: exportedEventCount / sessionEventCount / eventStreamActive in the request and report-metadata.json; the modal warns while event pages are still streaming - includedDiagnostics records sourceBlobName/sourceDestination so a failed copy still points at the original archive - tenantContext snapshot (fail-soft): upload mode/destination, gather- rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME pattern IDs — no secrets, no definitions - robustness: 8 MB client-side screenshot cap (20 MB body cap headroom), attachments status in metadata instead of silently dropping invalid base64, SanitizeZipEntryName strips path components from customer- supplied file names (both report types) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Customer-supplied attachment file names go verbatim into ZIP entry names of the
|
||||
/// session-report archive. The sanitizer must strip path components so a crafted name
|
||||
/// cannot produce traversal-shaped entries that an extraction tool would write outside
|
||||
/// the target directory.
|
||||
/// </summary>
|
||||
public class SessionReportZipEntryNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("agent.log", "agent.log")]
|
||||
[InlineData("agent_20260729.log", "agent_20260729.log")]
|
||||
[InlineData("logs/agent.log", "agent.log")]
|
||||
[InlineData("..\\..\\evil.exe", "evil.exe")]
|
||||
[InlineData("../../../etc/passwd", "passwd")]
|
||||
[InlineData("C:\\Windows\\System32\\bad.dll", "bad.dll")]
|
||||
[InlineData(" spaced.log ", "spaced.log")]
|
||||
public void SanitizeZipEntryName_StripsPathComponents(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("..")]
|
||||
[InlineData(".")]
|
||||
[InlineData("logs/")]
|
||||
[InlineData("a/../")]
|
||||
public void SanitizeZipEntryName_FallsBackWhenNothingUsable(string? input)
|
||||
{
|
||||
Assert.Equal("fallback.log", SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
@@ -26,6 +27,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly BlobStorageService _blobStorage;
|
||||
private readonly TableStorageService _tableStorage;
|
||||
private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private const string ContainerName = "session-reports";
|
||||
|
||||
public SessionReportService(
|
||||
@@ -33,6 +36,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
BlobStorageService blobStorage,
|
||||
TableStorageService tableStorage,
|
||||
SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier,
|
||||
TenantConfigurationService configService,
|
||||
IRuleRepository ruleRepo,
|
||||
ILogger<SessionReportService> logger)
|
||||
{
|
||||
_notificationRepo = notificationRepo;
|
||||
@@ -40,6 +45,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_blobStorage = blobStorage;
|
||||
_tableStorage = tableStorage;
|
||||
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
|
||||
_configService = configService;
|
||||
_ruleRepo = ruleRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -58,8 +65,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Session diag blobs die with the session (user delete / retention); the copy in
|
||||
// the session-reports container does not. Fail-soft: a failed copy never blocks
|
||||
// the report — the status lands on the row + in report-metadata.json instead.
|
||||
// The source blob name/destination is recorded too: when the copy failed, the
|
||||
// operator can still fetch the original while the session is alive.
|
||||
string? copiedDiagnosticsBlobName = null;
|
||||
string? diagnosticsCopyStatus = null;
|
||||
string? sourceDiagnosticsBlobName = null;
|
||||
string? sourceDiagnosticsDestination = null;
|
||||
if (request.IncludeDiagnostics)
|
||||
{
|
||||
var session = await _tableStorage.GetSessionAsync(request.TenantId, request.SessionId);
|
||||
@@ -69,6 +80,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
else
|
||||
{
|
||||
sourceDiagnosticsBlobName = session.DiagnosticsBlobName;
|
||||
sourceDiagnosticsDestination = session.DiagnosticsBlobDestination;
|
||||
var destinationName = $"{request.TenantId}_{request.SessionId}_diag_archive_{timestamp}.zip";
|
||||
var copyResult = await _diagnosticsArchiveCopier.CopyAsync(
|
||||
request.TenantId, request.SessionId, session.DiagnosticsBlobName, destinationName);
|
||||
@@ -78,6 +91,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant-side detection context (custom rule/pattern state at report time — it may
|
||||
// have changed by the time an operator investigates). Fail-soft; never blocks.
|
||||
var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId);
|
||||
|
||||
// Decode attachments up front so report-metadata.json can record their outcome
|
||||
// instead of silently dropping invalid payloads.
|
||||
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
|
||||
var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "agent log");
|
||||
|
||||
// 1. Create ZIP in memory
|
||||
using var zipStream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
@@ -116,47 +138,45 @@ namespace AutopilotMonitor.Functions.Services
|
||||
request.Email,
|
||||
submittedBy,
|
||||
submittedAt = DateTime.UtcNow.ToString("O"),
|
||||
export = new
|
||||
{
|
||||
exportedEventCount = request.ExportedEventCount,
|
||||
sessionEventCount = request.SessionEventCount,
|
||||
eventStreamActive = request.EventStreamActive
|
||||
},
|
||||
includedDiagnostics = new
|
||||
{
|
||||
requested = request.IncludeDiagnostics,
|
||||
blobName = copiedDiagnosticsBlobName,
|
||||
status = diagnosticsCopyStatus
|
||||
}
|
||||
status = diagnosticsCopyStatus,
|
||||
sourceBlobName = sourceDiagnosticsBlobName,
|
||||
sourceDestination = sourceDiagnosticsDestination
|
||||
},
|
||||
attachments = new
|
||||
{
|
||||
screenshot = screenshotStatus,
|
||||
agentLog = agentLogStatus
|
||||
},
|
||||
tenantContext
|
||||
});
|
||||
|
||||
// Optional screenshot
|
||||
if (!string.IsNullOrEmpty(request.ScreenshotBase64))
|
||||
if (screenshotBytes != null)
|
||||
{
|
||||
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 report {ReportId}", reportId);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Optional agent log file (max 5 MB enforced by frontend)
|
||||
if (!string.IsNullOrEmpty(request.AgentLogBase64))
|
||||
if (agentLogBytes != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logBytes = Convert.FromBase64String(request.AgentLogBase64);
|
||||
var logFileName = request.AgentLogFileName ?? "agent.log";
|
||||
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(logBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 agent log data in report {ReportId}", reportId);
|
||||
}
|
||||
var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "agent.log");
|
||||
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(agentLogBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +230,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip";
|
||||
|
||||
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
|
||||
var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "log payload");
|
||||
|
||||
using var zipStream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
@@ -221,42 +244,29 @@ namespace AutopilotMonitor.Functions.Services
|
||||
request.Comment,
|
||||
request.Email,
|
||||
submittedBy,
|
||||
submittedAt = DateTime.UtcNow.ToString("O")
|
||||
submittedAt = DateTime.UtcNow.ToString("O"),
|
||||
attachments = new
|
||||
{
|
||||
screenshot = screenshotStatus,
|
||||
agentLog = agentLogStatus
|
||||
}
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(request.ScreenshotBase64))
|
||||
if (screenshotBytes != null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.AgentLogBase64))
|
||||
if (agentLogBytes != null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "diag-files.bin");
|
||||
var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(agentLogBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +325,81 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return await _notificationRepo.UpdateSessionReportAdminNoteAsync(reportId, adminNote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the tenant-side detection state relevant to a session report: upload
|
||||
/// destination flags and the IDs of custom rules/patterns active at report time.
|
||||
/// Deliberately excludes anything secret-bearing (SAS URLs, tokens) and full rule
|
||||
/// definitions — IDs + timestamps are enough to correlate with the (persisted) rule
|
||||
/// tables even after the customer edits them. Fail-soft: null on any error.
|
||||
/// </summary>
|
||||
private async Task<object?> CollectTenantContextAsync(string tenantId, string reportId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = await _configService.GetConfigurationAsync(tenantId);
|
||||
var gatherRules = await _ruleRepo.GetGatherRulesAsync(tenantId);
|
||||
var analyzeRules = await _ruleRepo.GetAnalyzeRulesAsync(tenantId);
|
||||
var imePatterns = await _ruleRepo.GetImeLogPatternsAsync(tenantId);
|
||||
|
||||
return new
|
||||
{
|
||||
diagnosticsUploadMode = config.DiagnosticsUploadMode,
|
||||
diagnosticsUploadDestination = config.DiagnosticsUploadDestination,
|
||||
gatherRuleDebugLogEnabled = config.EnableGatherRuleDebugLog,
|
||||
customGatherRules = gatherRules
|
||||
.Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(),
|
||||
customAnalyzeRules = analyzeRules
|
||||
.Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(),
|
||||
customImePatternIds = imePatterns.Select(p => p.PatternId).ToList()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Report {ReportId}: tenant-context snapshot failed — omitted from metadata", reportId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
|
||||
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —
|
||||
/// an invalid payload must be visible to the operator, not silently dropped.
|
||||
/// </summary>
|
||||
private (byte[]? Bytes, string Status) DecodeAttachment(string? base64, string reportId, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64))
|
||||
return (null, "none");
|
||||
try
|
||||
{
|
||||
return (Convert.FromBase64String(base64), "included");
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid base64 {Label} data in report {ReportId}", label, reportId);
|
||||
return (null, "invalid-base64");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a caller-supplied file name for use as a ZIP entry: strips any path
|
||||
/// components (no traversal-shaped entries inside the archive) and falls back to
|
||||
/// <paramref name="fallback"/> when nothing usable remains.
|
||||
/// </summary>
|
||||
internal static string SanitizeZipEntryName(string? fileName, string fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return fallback;
|
||||
// Normalize both separator styles, then keep only the final segment.
|
||||
var candidate = fileName.Replace('\\', '/');
|
||||
var lastSlash = candidate.LastIndexOf('/');
|
||||
if (lastSlash >= 0)
|
||||
candidate = candidate[(lastSlash + 1)..];
|
||||
candidate = candidate.Trim();
|
||||
if (string.IsNullOrEmpty(candidate) || candidate == "." || candidate == "..")
|
||||
return fallback;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static void AddJsonEntry(ZipArchive archive, string name, object data)
|
||||
{
|
||||
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
|
||||
|
||||
@@ -57,6 +57,16 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// session deletion and retention cleanup.
|
||||
/// </summary>
|
||||
public bool IncludeDiagnostics { get; set; }
|
||||
|
||||
/// <summary>Number of events the client had loaded when it generated the exports.</summary>
|
||||
public int? ExportedEventCount { get; set; }
|
||||
|
||||
/// <summary>The session's EventCount as the client saw it — compared against
|
||||
/// <see cref="ExportedEventCount"/> this flags a partial export.</summary>
|
||||
public int? SessionEventCount { get; set; }
|
||||
|
||||
/// <summary>True when the client was still streaming event pages at submit time.</summary>
|
||||
public bool? EventStreamActive { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { Session, EnrollmentEvent, RuleResult } from "@/types";
|
||||
|
||||
const MAX_AGENT_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
// Backend caps the whole request body at 20 MB. Base64 adds ~33%, and the body also
|
||||
// carries agent logs (≤5 MB raw) plus CSV/TXT exports — 8 MB raw screenshots keeps
|
||||
// the worst case comfortably under the cap.
|
||||
const MAX_SCREENSHOT_SIZE = 8 * 1024 * 1024; // 8 MB
|
||||
|
||||
interface ReportSessionModalProps {
|
||||
show: boolean;
|
||||
@@ -18,6 +22,8 @@ interface ReportSessionModalProps {
|
||||
) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
submitting: boolean;
|
||||
/** True while the timeline is still streaming event pages — the export would be partial. */
|
||||
eventsStreaming?: boolean;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -27,11 +33,12 @@ function formatFileSize(bytes: number): string {
|
||||
}
|
||||
|
||||
export default function ReportSessionModal({
|
||||
show, session, events, analysisResults, onSubmit, onCancel, submitting
|
||||
show, session, events, analysisResults, onSubmit, onCancel, submitting, eventsStreaming
|
||||
}: ReportSessionModalProps) {
|
||||
const [comment, setComment] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [screenshotFiles, setScreenshotFiles] = useState<File[]>([]);
|
||||
const [screenshotError, setScreenshotError] = useState<string | null>(null);
|
||||
const [agentLogFiles, setAgentLogFiles] = useState<File[]>([]);
|
||||
const [agentLogError, setAgentLogError] = useState<string | null>(null);
|
||||
const [includeDiagnostics, setIncludeDiagnostics] = useState(true);
|
||||
@@ -77,17 +84,25 @@ export default function ReportSessionModal({
|
||||
};
|
||||
|
||||
const addScreenshots = (newFiles: File[]) => {
|
||||
setScreenshotError(null);
|
||||
const merged = [...screenshotFiles];
|
||||
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_SCREENSHOT_SIZE) {
|
||||
setScreenshotError(`Total size (${formatFileSize(totalSize)}) exceeds 8 MB limit. Remove some files or add smaller ones.`);
|
||||
// Keep existing files, don't add the new ones
|
||||
return;
|
||||
}
|
||||
setScreenshotFiles(merged);
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const removeScreenshot = (index: number) => {
|
||||
setScreenshotError(null);
|
||||
setScreenshotFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
@@ -148,6 +163,7 @@ export default function ReportSessionModal({
|
||||
setComment("");
|
||||
setEmail("");
|
||||
setScreenshotFiles([]);
|
||||
setScreenshotError(null);
|
||||
setAgentLogFiles([]);
|
||||
setAgentLogError(null);
|
||||
} catch (err: unknown) {
|
||||
@@ -304,7 +320,7 @@ export default function ReportSessionModal({
|
||||
{/* Screenshots */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Screenshots <span className="text-gray-400">(optional)</span>
|
||||
Screenshots <span className="text-gray-400">(optional, max 8 MB total)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={screenshotInputRef}
|
||||
@@ -315,6 +331,9 @@ export default function ReportSessionModal({
|
||||
className="w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900/40 dark:file:text-blue-300"
|
||||
disabled={submitting}
|
||||
/>
|
||||
{screenshotError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{screenshotError}</p>
|
||||
)}
|
||||
{screenshotFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{screenshotFiles.map((file, i) => (
|
||||
@@ -374,6 +393,12 @@ export default function ReportSessionModal({
|
||||
{screenshotFiles.length > 1 && <li>{screenshotFiles.length} screenshots (will be zipped)</li>}
|
||||
{hasDiagnostics && includeDiagnostics && <li>Uploaded diagnostics archive (copied server-side)</li>}
|
||||
</ul>
|
||||
{eventsStreaming && (
|
||||
<p className="mt-2 text-amber-700 dark:text-amber-400">
|
||||
⚠ The event timeline is still loading — a report submitted now will contain a
|
||||
partial export. Wait a moment for the complete timeline if possible.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -398,7 +423,7 @@ export default function ReportSessionModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !!agentLogError}
|
||||
disabled={submitting || !!agentLogError || !!screenshotError}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
|
||||
@@ -305,7 +305,12 @@ function SessionDetailContent() {
|
||||
screenshotFileName,
|
||||
agentLogBase64,
|
||||
agentLogFileName,
|
||||
includeDiagnostics
|
||||
includeDiagnostics,
|
||||
// Completeness signal: lets the operator detect a partial export
|
||||
// (client had not finished streaming all event pages at submit time).
|
||||
exportedEventCount: events.length,
|
||||
sessionEventCount: session?.eventCount,
|
||||
eventStreamActive: eventsApi.isStreamingMore
|
||||
})
|
||||
}
|
||||
);
|
||||
@@ -782,6 +787,7 @@ function SessionDetailContent() {
|
||||
onSubmit={handleSubmitReport}
|
||||
onCancel={() => setShowReportModal(false)}
|
||||
submitting={reportSubmitting}
|
||||
eventsStreaming={eventsApi.isStreamingMore}
|
||||
/>
|
||||
|
||||
</main>
|
||||
|
||||
@@ -103,13 +103,30 @@ export interface SessionCsvData {
|
||||
lastEventAt?: string;
|
||||
durationSeconds: number;
|
||||
diagnosticsBlobName?: string;
|
||||
rebootCount?: number;
|
||||
failureSource?: string;
|
||||
reconcileReason?: string;
|
||||
adminMarkedAction?: string;
|
||||
validatedBy?: string;
|
||||
isHybridJoin?: boolean;
|
||||
isSelfDeployingProfile?: boolean;
|
||||
connectionType?: string;
|
||||
geoCountry?: string;
|
||||
geoRegion?: string;
|
||||
geoCity?: string;
|
||||
avgApiLatencyMs?: number;
|
||||
apiRequestCount?: number;
|
||||
stalledAt?: string;
|
||||
failureSnapshotJson?: string;
|
||||
}
|
||||
|
||||
export function generateSessionCsvExport(session: SessionCsvData): string {
|
||||
const esc = (v: string | undefined | null) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
||||
// Columns match the Sessions Azure Table Storage schema exactly
|
||||
// Columns mirror the Sessions Azure Table Storage schema
|
||||
// PartitionKey = TenantId, RowKey = SessionId
|
||||
const header = "PartitionKey,RowKey,SerialNumber,DeviceName,Manufacturer,Model,OsName,OsBuild,OsDisplayVersion,OsEdition,OsLanguage,IsUserDriven,IsPreProvisioned,StartedAt,CompletedAt,AgentVersion,EnrollmentType,CurrentPhase,Status,EventCount,FailureReason,LastEventAt,DurationSeconds,DiagnosticsBlobName";
|
||||
// Newer fields are APPENDED (never inserted) so the column prefix stays stable
|
||||
// for anything parsing older exports.
|
||||
const header = "PartitionKey,RowKey,SerialNumber,DeviceName,Manufacturer,Model,OsName,OsBuild,OsDisplayVersion,OsEdition,OsLanguage,IsUserDriven,IsPreProvisioned,StartedAt,CompletedAt,AgentVersion,EnrollmentType,CurrentPhase,Status,EventCount,FailureReason,LastEventAt,DurationSeconds,DiagnosticsBlobName,RebootCount,FailureSource,ReconcileReason,AdminMarkedAction,ValidatedBy,IsHybridJoin,IsSelfDeployingProfile,ConnectionType,GeoCountry,GeoRegion,GeoCity,AvgApiLatencyMs,ApiRequestCount,StalledAt,FailureSnapshotJson";
|
||||
const row = [
|
||||
esc(session.tenantId),
|
||||
esc(session.sessionId),
|
||||
@@ -135,6 +152,21 @@ export function generateSessionCsvExport(session: SessionCsvData): string {
|
||||
esc(session.lastEventAt),
|
||||
String(session.durationSeconds ?? ""),
|
||||
esc(session.diagnosticsBlobName),
|
||||
String(session.rebootCount ?? ""),
|
||||
esc(session.failureSource),
|
||||
esc(session.reconcileReason),
|
||||
esc(session.adminMarkedAction),
|
||||
esc(session.validatedBy),
|
||||
String(session.isHybridJoin ?? ""),
|
||||
String(session.isSelfDeployingProfile ?? ""),
|
||||
esc(session.connectionType),
|
||||
esc(session.geoCountry),
|
||||
esc(session.geoRegion),
|
||||
esc(session.geoCity),
|
||||
String(session.avgApiLatencyMs ?? ""),
|
||||
String(session.apiRequestCount ?? ""),
|
||||
esc(session.stalledAt),
|
||||
esc(session.failureSnapshotJson),
|
||||
].join(",");
|
||||
return "\uFEFF" + header + "\n" + row;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user