mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor
This commit is contained in:
@@ -1 +1 @@
|
||||
637
|
||||
638
|
||||
|
||||
@@ -1 +1 @@
|
||||
1275
|
||||
1276
|
||||
|
||||
+6
-2
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="SessionReportMetadata.ReportType"/> = "diagFiles".
|
||||
/// </summary>
|
||||
public class SubmitDiagFilesReportFunction
|
||||
{
|
||||
private readonly ILogger<SubmitDiagFilesReportFunction> _logger;
|
||||
private readonly SessionReportService _sessionReportService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
|
||||
public SubmitDiagFilesReportFunction(
|
||||
ILogger<SubmitDiagFilesReportFunction> logger,
|
||||
SessionReportService sessionReportService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
GlobalNotificationService globalNotificationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionReportService = sessionReportService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
}
|
||||
|
||||
[Function("SubmitDiagFilesReport")]
|
||||
public async Task<HttpResponseData> 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<SubmitDiagFilesReportRequest>();
|
||||
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<string, string>
|
||||
{
|
||||
{ "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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<SessionReportMetadata> 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);
|
||||
|
||||
@@ -105,6 +105,42 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts content to a webhook URL with a single retry on transient failures (429, 5xx, network errors).
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,20 @@ using System;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Discriminator values for the ReportType field on <see cref="SessionReportMetadata"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ReportTypes
|
||||
{
|
||||
/// <summary>Submitted from the session detail page; ZIP carries session/events/timeline exports.</summary>
|
||||
public const string Session = "session";
|
||||
|
||||
/// <summary>Submitted from /settings/tenant/support; ZIP carries only user-attached files.</summary>
|
||||
public const string DiagFiles = "diagFiles";
|
||||
}
|
||||
|
||||
/// <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.
|
||||
@@ -38,6 +52,30 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string AgentLogFileName { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SubmitDiagFilesReportRequest
|
||||
{
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Comment { get; set; } = default!;
|
||||
public string Email { get; set; } = default!;
|
||||
|
||||
/// <summary>Base64-encoded screenshot image (optional)</summary>
|
||||
public string ScreenshotBase64 { get; set; } = default!;
|
||||
|
||||
/// <summary>Original screenshot file name for extension detection</summary>
|
||||
public string ScreenshotFileName { get; set; } = default!;
|
||||
|
||||
/// <summary>Base64-encoded log/state payload (single file or zip of many; max ~5 MB enforced client-side)</summary>
|
||||
public string AgentLogBase64 { get; set; } = default!;
|
||||
|
||||
/// <summary>Original file name (e.g. "agent.log", "state.json", "diag-files.zip")</summary>
|
||||
public string AgentLogFileName { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from session report submission
|
||||
/// </summary>
|
||||
@@ -49,7 +87,10 @@ namespace AutopilotMonitor.Shared.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ReportType"/> discriminator distinguishes the two, and
|
||||
/// <see cref="SessionId"/> is empty for diag-files submissions.
|
||||
/// </summary>
|
||||
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!;
|
||||
|
||||
/// <summary>One of <see cref="ReportTypes"/>. Defaults to "session" so legacy rows map cleanly.</summary>
|
||||
public string ReportType { get; set; } = ReportTypes.Session;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionReportsSectionProps {
|
||||
@@ -304,6 +319,7 @@ export function SessionReportsSection({
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Session</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tenant</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Submitted By</th>
|
||||
@@ -321,8 +337,13 @@ export function SessionReportsSection({
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||
{new Date(r.submittedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<ReportTypeBadge type={r.reportType} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{r.sessionId.length > 8 ? `${r.sessionId.slice(0, 8)}...` : r.sessionId}
|
||||
{r.reportType === "diagFiles"
|
||||
? <span className="text-gray-300 dark:text-gray-600">—</span>
|
||||
: r.sessionId.length > 8 ? `${r.sessionId.slice(0, 8)}...` : r.sessionId}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{r.tenantId.length > 8 ? `${r.tenantId.slice(0, 8)}...` : r.tenantId}
|
||||
@@ -396,12 +417,20 @@ export function SessionReportsSection({
|
||||
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Session ID</dt>
|
||||
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
|
||||
{selectedReport.sessionId}
|
||||
<CopyButton value={selectedReport.sessionId} />
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Type</dt>
|
||||
<dd className="mt-0.5">
|
||||
<ReportTypeBadge type={selectedReport.reportType} />
|
||||
</dd>
|
||||
</div>
|
||||
{selectedReport.reportType !== "diagFiles" && selectedReport.sessionId && (
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Session ID</dt>
|
||||
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
|
||||
{selectedReport.sessionId}
|
||||
<CopyButton value={selectedReport.sessionId} />
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Tenant ID</dt>
|
||||
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
|
||||
|
||||
@@ -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<TenantSectionId, React.ComponentType> = {
|
||||
"autopilot": SectionAutopilotValidation,
|
||||
@@ -17,6 +18,7 @@ const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
|
||||
"sla-targets": SectionSlaTargets,
|
||||
"access-management": SectionAccessManagement,
|
||||
"bootstrap-sessions": SectionBootstrapSessions,
|
||||
"support": SectionSubmitLogs,
|
||||
};
|
||||
|
||||
export default function TenantSectionPage() {
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, Uint8Array> = {};
|
||||
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<File[]>([]);
|
||||
const [screenshotFiles, setScreenshotFiles] = useState<File[]>([]);
|
||||
const [logError, setLogError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitResult, setSubmitResult] = useState<"success" | "error" | null>(null);
|
||||
const [submitErrorMessage, setSubmitErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const logInputRef = useRef<HTMLInputElement>(null);
|
||||
const screenshotInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="bg-white rounded-lg shadow p-6 max-w-2xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Submit Logs to Support</h2>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 mt-1">
|
||||
Private Preview
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{submitResult === "success" && (
|
||||
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-green-800">Report submitted successfully</p>
|
||||
<p className="text-sm text-green-700 mt-0.5">
|
||||
The diagnostic files have been sent for analysis by the Autopilot Monitor team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitResult === "error" && submitErrorMessage && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-800">Failed to submit report</p>
|
||||
<p className="text-sm text-red-700 mt-0.5">{submitErrorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Comment <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="What's going on? Which device / scenario do these files relate to?"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
rows={3}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="your.email@company.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
No guarantee of response. Issues may be silently fixed — check the changelog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Log / state files */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Log & state files <span className="text-gray-400">(optional, max 5 MB total)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={logInputRef}
|
||||
type="file"
|
||||
accept={LOG_ACCEPT}
|
||||
multiple
|
||||
onChange={e => addLogs(e.target.files ? Array.from(e.target.files) : [])}
|
||||
className="w-full text-sm text-gray-500 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"
|
||||
disabled={submitting}
|
||||
/>
|
||||
{logError && <p className="text-xs text-red-600 mt-1">{logError}</p>}
|
||||
{logFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{logFiles.map((file, i) => (
|
||||
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between bg-gray-50 rounded px-2 py-1 text-xs text-gray-600">
|
||||
<span className="truncate mr-2">{file.name} ({formatFileSize(file.size)})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLog(i)}
|
||||
disabled={submitting}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-red-500 disabled:opacity-50"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-gray-400">
|
||||
{logFiles.length} file{logFiles.length !== 1 ? "s" : ""} — {formatFileSize(logTotalSize)} total
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Accepted: .log, .txt, .zip, .json, .jsonl, .ndjson. Multiple files are zipped automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Screenshots <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={screenshotInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={e => addScreenshots(e.target.files ? Array.from(e.target.files) : [])}
|
||||
className="w-full text-sm text-gray-500 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"
|
||||
disabled={submitting}
|
||||
/>
|
||||
{screenshotFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{screenshotFiles.map((file, i) => (
|
||||
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between bg-gray-50 rounded px-2 py-1 text-xs text-gray-600">
|
||||
<span className="truncate mr-2">{file.name} ({formatFileSize(file.size)})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeScreenshot(i)}
|
||||
disabled={submitting}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-red-500 disabled:opacity-50"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !!logError || !hasAnyContent}
|
||||
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"
|
||||
title={!hasAnyContent ? "Add a comment, log file, or screenshot first" : undefined}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
"Submit Report"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const TENANT_NAV_SECTIONS = [
|
||||
{ id: "notifications", label: "Notifications", description: "Webhook notification configuration" },
|
||||
{ id: "sla-targets", label: "SLA Targets", description: "SLA targets and breach notification settings" },
|
||||
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" },
|
||||
{ id: "support", label: "Submit Logs", description: "Send diagnostic files to the Autopilot Monitor team" },
|
||||
] as const;
|
||||
|
||||
export type TenantSectionId = (typeof TENANT_NAV_SECTIONS)[number]["id"];
|
||||
|
||||
@@ -285,6 +285,11 @@ export const api = {
|
||||
`${API_BASE_URL}/api/global/session-reports/${reportId}/note`,
|
||||
},
|
||||
|
||||
// ── Diag Files Reports (no session context) ───────────────────────────────
|
||||
diagFilesReports: {
|
||||
submit: () => `${API_BASE_URL}/api/diag-files-reports`,
|
||||
},
|
||||
|
||||
// ── Distress Reports ──────────────────────────────────────────────────────
|
||||
distressReports: {
|
||||
list: () => `${API_BASE_URL}/api/global/distress-reports`,
|
||||
|
||||
Reference in New Issue
Block a user