mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fetch diagnostics SAS URL on-demand to reduce device exposure window
Instead of delivering the long-lived SAS URL in the agent config response (where it was cached on disk for the full enrollment period), the agent now requests a short-lived URL via POST /api/diagnostics/upload-url just before uploading — the URL is held in memory only and never written to config. Backend: new GetDiagnosticsUploadUrlFunction with same cert/hardware security validation as all other agent endpoints; GetAgentConfigFunction now sends DiagnosticsUploadEnabled (bool) instead of the SAS URL; ConfigVersion bumped to 6. Agent: BackendApiClient.GetDiagnosticsUploadUrlAsync added; DiagnosticsPackageService fetches URL on-demand via the API client; AgentConfiguration.DiagnosticsBlobSasUrl replaced by DiagnosticsUploadEnabled. UI: settings page parses SAS URL expiry (se= param) and shows expiry date inline with colour coding; expired or near-expiry URLs trigger error/warning notifications at the top of the page on load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -144,10 +144,11 @@ namespace AutopilotMonitor.Agent.Core.Configuration
|
||||
public int AuthFailureTimeoutMinutes { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Blob Storage Container SAS URL for diagnostics package upload.
|
||||
/// The agent uploads directly to the tenant's storage.
|
||||
/// Whether diagnostics upload is configured for this tenant.
|
||||
/// When true, the agent requests a short-lived upload URL from the backend just before uploading.
|
||||
/// The SAS URL itself is never stored in config — it is fetched on-demand and used in memory only.
|
||||
/// </summary>
|
||||
public string DiagnosticsBlobSasUrl { get; set; }
|
||||
public bool DiagnosticsUploadEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
|
||||
|
||||
+26
-10
@@ -6,18 +6,21 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Network;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// to the tenant's Azure Blob Storage container via a short-lived SAS URL.
|
||||
/// The SAS URL is fetched on-demand just before upload and never stored in config or on disk.
|
||||
/// </summary>
|
||||
public class DiagnosticsPackageService
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private static readonly string ImeLogFolder =
|
||||
@@ -26,10 +29,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
private static readonly string AgentLogFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
|
||||
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger)
|
||||
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_apiClient = apiClient;
|
||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
}
|
||||
|
||||
@@ -50,9 +54,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl))
|
||||
if (!_configuration.DiagnosticsUploadEnabled)
|
||||
{
|
||||
_logger.Debug("Diagnostics upload skipped: no Blob Storage SAS URL configured");
|
||||
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -89,8 +93,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
|
||||
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
|
||||
|
||||
// Upload to Blob Storage
|
||||
var uploaded = await UploadToBlobStorageAsync(zipFileName, zipBytes);
|
||||
// Fetch a short-lived upload URL just before uploading — never stored in config or on disk
|
||||
_logger.Info("Requesting diagnostics upload URL from backend...");
|
||||
var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync(
|
||||
_configuration.TenantId,
|
||||
_configuration.SessionId,
|
||||
zipFileName);
|
||||
|
||||
if (uploadUrlResponse == null || !uploadUrlResponse.Success || string.IsNullOrEmpty(uploadUrlResponse.UploadUrl))
|
||||
{
|
||||
_logger.Warning("Failed to get diagnostics upload URL from backend — skipping upload");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Upload to Blob Storage using the freshly obtained URL
|
||||
var uploaded = await UploadToBlobStorageAsync(zipFileName, zipBytes, uploadUrlResponse.UploadUrl);
|
||||
if (uploaded)
|
||||
{
|
||||
_logger.Info($"Diagnostics package uploaded successfully: {zipFileName}");
|
||||
@@ -171,14 +188,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the ZIP bytes to the configured Azure Blob Storage container via PUT with SAS token.
|
||||
/// Uploads the ZIP bytes to Azure Blob Storage via PUT with the provided container SAS URL.
|
||||
/// 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.
|
||||
/// The URL is passed in directly (fetched on-demand just before upload, never from config).
|
||||
/// </summary>
|
||||
private async Task<bool> UploadToBlobStorageAsync(string blobName, byte[] data)
|
||||
private async Task<bool> UploadToBlobStorageAsync(string blobName, byte[] data, string containerSasUrl)
|
||||
{
|
||||
var containerSasUrl = _configuration.DiagnosticsBlobSasUrl;
|
||||
|
||||
// Build blob URL: split at '?' to insert blob name
|
||||
var questionMarkIndex = containerSasUrl.IndexOf('?');
|
||||
string blobUrl;
|
||||
|
||||
@@ -70,7 +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);
|
||||
_diagnosticsService = new DiagnosticsPackageService(_configuration, _logger, _apiClient);
|
||||
|
||||
// Subscribe to spool events for batched upload when new events arrive
|
||||
_spool.EventsAvailable += OnEventsAvailable;
|
||||
@@ -255,7 +255,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
_configuration.RebootOnComplete = config.RebootOnComplete;
|
||||
_configuration.RebootDelaySeconds = config.RebootDelaySeconds;
|
||||
_configuration.MaxBatchSize = config.MaxBatchSize;
|
||||
_configuration.DiagnosticsBlobSasUrl = config.DiagnosticsBlobSasUrl;
|
||||
_configuration.DiagnosticsUploadEnabled = config.DiagnosticsUploadEnabled;
|
||||
_configuration.DiagnosticsUploadMode = config.DiagnosticsUploadMode;
|
||||
|
||||
// Apply log level from remote config
|
||||
@@ -743,7 +743,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl))
|
||||
if (!_configuration.DiagnosticsUploadEnabled)
|
||||
return;
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
|
||||
return;
|
||||
|
||||
@@ -145,6 +145,25 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a short-lived SAS URL for diagnostics package upload.
|
||||
/// Called just before upload so the URL is never stored in config or on disk.
|
||||
/// Returns null if the request fails (non-fatal — diagnostics upload is best-effort).
|
||||
/// </summary>
|
||||
public async Task<GetDiagnosticsUploadUrlResponse> GetDiagnosticsUploadUrlAsync(
|
||||
string tenantId, string sessionId, string fileName)
|
||||
{
|
||||
var url = $"{_baseUrl}{Constants.ApiEndpoints.GetDiagnosticsUploadUrl}";
|
||||
var request = new GetDiagnosticsUploadUrlRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
FileName = fileName
|
||||
};
|
||||
var response = await PostAsync<GetDiagnosticsUploadUrlRequest, GetDiagnosticsUploadUrlResponse>(url, request);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<TResponse> PostAsync<TRequest, TResponse>(string url, TRequest data)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(data);
|
||||
|
||||
@@ -90,7 +90,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 5,
|
||||
ConfigVersion = 6,
|
||||
UploadIntervalSeconds = 30,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
@@ -102,7 +102,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
RebootOnComplete = tenantConfig.RebootOnComplete ?? false,
|
||||
RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10,
|
||||
MaxBatchSize = tenantConfig.MaxBatchSize ?? 100,
|
||||
DiagnosticsBlobSasUrl = tenantConfig.DiagnosticsBlobSasUrl,
|
||||
DiagnosticsUploadEnabled = !string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl),
|
||||
DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off",
|
||||
Collectors = collectors,
|
||||
GatherRules = gatherRules,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a short-lived SAS URL for diagnostics package upload.
|
||||
/// Called by the agent just before uploading diagnostics — not at startup.
|
||||
/// The SAS URL is never cached in the agent's config, reducing its exposure window.
|
||||
/// Uses device authentication (client certificate), not JWT.
|
||||
/// </summary>
|
||||
public class GetDiagnosticsUploadUrlFunction
|
||||
{
|
||||
private readonly ILogger<GetDiagnosticsUploadUrlFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
|
||||
public GetDiagnosticsUploadUrlFunction(
|
||||
ILogger<GetDiagnosticsUploadUrlFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
}
|
||||
|
||||
[Function("GetDiagnosticsUploadUrl")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "diagnostics/upload-url")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse request body
|
||||
GetDiagnosticsUploadUrlRequest? requestBody;
|
||||
try
|
||||
{
|
||||
requestBody = await System.Text.Json.JsonSerializer.DeserializeAsync<GetDiagnosticsUploadUrlRequest>(
|
||||
req.Body,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid request body"
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
if (requestBody == null || string.IsNullOrEmpty(requestBody.TenantId))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "tenantId is required"
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Validate request security (certificate, rate limit, hardware whitelist)
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
requestBody.TenantId,
|
||||
_configService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_logger
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
{
|
||||
return errorResponse;
|
||||
}
|
||||
|
||||
// Get tenant configuration
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(requestBody.TenantId);
|
||||
|
||||
if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl))
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Diagnostics storage not configured for this tenant"
|
||||
});
|
||||
return notFound;
|
||||
}
|
||||
|
||||
// Parse expiry from the SAS URL's se= parameter
|
||||
var sasExpiry = ParseSasExpiry(tenantConfig.DiagnosticsBlobSasUrl);
|
||||
|
||||
// Log the request — but never log the SAS URL itself
|
||||
_logger.LogInformation(
|
||||
"GetDiagnosticsUploadUrl: Issuing upload URL for tenant {TenantId}, session {SessionId}, file {FileName}, SAS expires {ExpiresAt}",
|
||||
requestBody.TenantId,
|
||||
requestBody.SessionId,
|
||||
requestBody.FileName,
|
||||
sasExpiry?.ToString("O") ?? "unknown");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = true,
|
||||
UploadUrl = tenantConfig.DiagnosticsBlobSasUrl,
|
||||
ExpiresAt = sasExpiry ?? DateTime.UtcNow.AddHours(1),
|
||||
Message = null
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting diagnostics upload URL");
|
||||
var errorResp = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResp.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Internal server error"
|
||||
});
|
||||
return errorResp;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the expiry datetime from the se= query parameter of a SAS URL.
|
||||
/// Returns null if the parameter is missing or cannot be parsed.
|
||||
/// </summary>
|
||||
private static DateTime? ParseSasExpiry(string sasUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var queryIndex = sasUrl.IndexOf('?');
|
||||
if (queryIndex < 0) return null;
|
||||
|
||||
var query = HttpUtility.ParseQueryString(sasUrl.Substring(queryIndex));
|
||||
var seValue = query["se"];
|
||||
if (string.IsNullOrEmpty(seValue)) return null;
|
||||
|
||||
if (DateTime.TryParse(seValue, null, System.Globalization.DateTimeStyles.RoundtripKind, out var expiry))
|
||||
return expiry;
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,13 @@ namespace AutopilotMonitor.Shared
|
||||
/// </summary>
|
||||
public static class ApiEndpoints
|
||||
{
|
||||
public const string RegisterSession = "/api/sessions/register";
|
||||
public const string IngestEvents = "/api/events/ingest";
|
||||
public const string UploadBundle = "/api/bundles/upload";
|
||||
public const string GetAgentConfig = "/api/agent/config";
|
||||
public const string GatherRules = "/api/gather-rules";
|
||||
public const string AnalyzeRules = "/api/analyze-rules";
|
||||
public const string RegisterSession = "/api/sessions/register";
|
||||
public const string IngestEvents = "/api/events/ingest";
|
||||
public const string UploadBundle = "/api/bundles/upload";
|
||||
public const string GetAgentConfig = "/api/agent/config";
|
||||
public const string GatherRules = "/api/gather-rules";
|
||||
public const string AnalyzeRules = "/api/analyze-rules";
|
||||
public const string GetDiagnosticsUploadUrl = "/api/diagnostics/upload-url";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -93,10 +93,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int MaxBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Whether diagnostics upload is configured for this tenant.
|
||||
/// When true, the agent should request a short-lived upload URL via the API just before uploading.
|
||||
/// The SAS URL itself is never included in this config response — it is fetched on-demand.
|
||||
/// </summary>
|
||||
public string DiagnosticsBlobSasUrl { get; set; }
|
||||
public bool DiagnosticsUploadEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
|
||||
|
||||
@@ -108,6 +108,28 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get a short-lived SAS URL for diagnostics package upload.
|
||||
/// Called by the agent just before upload — the URL is never cached in config.
|
||||
/// </summary>
|
||||
public class GetDiagnosticsUploadUrlRequest
|
||||
{
|
||||
public string TenantId { get; set; }
|
||||
public string SessionId { get; set; }
|
||||
public string FileName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a short-lived SAS URL for diagnostics package upload.
|
||||
/// </summary>
|
||||
public class GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string UploadUrl { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session summary for UI display
|
||||
/// </summary>
|
||||
|
||||
@@ -46,6 +46,21 @@ interface TenantAdmin {
|
||||
addedBy: string;
|
||||
}
|
||||
|
||||
/** Parses the expiry date from the `se=` parameter of a SAS URL. Returns null if not found. */
|
||||
function parseSasExpiry(sasUrl: string): Date | null {
|
||||
try {
|
||||
const qIndex = sasUrl.indexOf('?');
|
||||
if (qIndex < 0) return null;
|
||||
const params = new URLSearchParams(sasUrl.substring(qIndex + 1));
|
||||
const se = params.get('se');
|
||||
if (!se) return null;
|
||||
const d = new Date(se);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { tenantId } = useTenant();
|
||||
@@ -99,6 +114,8 @@ export default function SettingsPage() {
|
||||
// Diagnostics package state
|
||||
const [diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl] = useState("");
|
||||
const [diagnosticsUploadMode, setDiagnosticsUploadMode] = useState("Off");
|
||||
const [diagnosticsSasExpiry, setDiagnosticsSasExpiry] = useState<Date | null>(null);
|
||||
const [diagnosticsSasWarning, setDiagnosticsSasWarning] = useState<string | null>(null);
|
||||
|
||||
// Fetch configuration
|
||||
useEffect(() => {
|
||||
@@ -146,8 +163,26 @@ export default function SettingsPage() {
|
||||
setTeamsWebhookUrl(data.teamsWebhookUrl ?? "");
|
||||
setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
|
||||
setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
|
||||
setDiagnosticsBlobSasUrl(data.diagnosticsBlobSasUrl ?? "");
|
||||
const sasUrl = data.diagnosticsBlobSasUrl ?? "";
|
||||
setDiagnosticsBlobSasUrl(sasUrl);
|
||||
setDiagnosticsUploadMode(data.diagnosticsUploadMode ?? "Off");
|
||||
|
||||
// Parse SAS expiry and set warning/error notifications
|
||||
if (sasUrl) {
|
||||
const expiry = parseSasExpiry(sasUrl);
|
||||
setDiagnosticsSasExpiry(expiry);
|
||||
if (expiry) {
|
||||
const now = new Date();
|
||||
const daysRemaining = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysRemaining <= 0) {
|
||||
setDiagnosticsSasWarning("error");
|
||||
} else if (daysRemaining <= 7) {
|
||||
setDiagnosticsSasWarning("warning");
|
||||
} else {
|
||||
setDiagnosticsSasWarning(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching configuration:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load configuration");
|
||||
@@ -621,6 +656,28 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnostics SAS URL expiry notifications */}
|
||||
{diagnosticsSasWarning === "error" && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center space-x-3">
|
||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-800">
|
||||
<strong>Diagnostics SAS URL has expired.</strong> Diagnostics upload is non-functional. Please update the SAS URL in the Diagnostics Package section.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{diagnosticsSasWarning === "warning" && diagnosticsSasExpiry && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-center space-x-3">
|
||||
<svg className="w-5 h-5 text-amber-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
<span className="text-amber-900">
|
||||
<strong>Diagnostics SAS URL expires on {diagnosticsSasExpiry.toLocaleDateString()}</strong> — please update it soon to avoid interruption.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autopilot Device Validation */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
@@ -1237,7 +1294,7 @@ export default function SettingsPage() {
|
||||
{/* Info */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-900">
|
||||
The agent uploads diagnostics directly to the <strong>configured</strong> Blob Storage container. Data never passes through the backend.
|
||||
The agent requests a short-lived upload URL from the backend <strong>just before uploading</strong>. Your SAS URL is stored securely in the backend and never sent to devices in the agent configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1263,6 +1320,36 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* SAS URL expiry indicator */}
|
||||
{diagnosticsBlobSasUrl && diagnosticsSasExpiry && (() => {
|
||||
const now = new Date();
|
||||
const daysRemaining = Math.ceil((diagnosticsSasExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isExpired = daysRemaining <= 0;
|
||||
const isWarning = daysRemaining > 0 && daysRemaining <= 7;
|
||||
return (
|
||||
<div className={`mt-2 flex items-center gap-1.5 text-xs ${isExpired ? 'text-red-600' : isWarning ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{isExpired ? (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : isWarning ? (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
)}
|
||||
<span>
|
||||
{isExpired
|
||||
? `Expired on ${diagnosticsSasExpiry.toLocaleDateString()}`
|
||||
: `Expires on ${diagnosticsSasExpiry.toLocaleDateString()}${isWarning ? ` (${daysRemaining} day${daysRemaining === 1 ? '' : 's'} remaining)` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Upload Mode */}
|
||||
|
||||
Reference in New Issue
Block a user