diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs index 7f579277..7b487ca1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs @@ -34,6 +34,9 @@ namespace AutopilotMonitor.Functions.Functions if (stats == null) { // No stats computed yet - return zeros + // TODO: get values from azure blob storage /stats/platform-stats.json, calculation is done nightly in maintenance function and not here + // maybe used later for SWA-API passthrough - andvantage same does for usage metrics + // alternative landing receives stats from blob storage and not from this endpoint, then we can remove this endpoint and call blob storage directly from landing page (CORS) await response.WriteAsJsonAsync(new { totalEnrollments = 0, @@ -42,7 +45,8 @@ namespace AutopilotMonitor.Functions.Functions uniqueDeviceModels = 0, totalEventsProcessed = 0, successfulEnrollments = 0, - issuesDetected = 0 + issuesDetected = 0, + lastUpdated = (DateTime?)null }); } else diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs index 75b600f3..d6dace29 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetRuleResultsFunction.cs @@ -33,13 +33,6 @@ namespace AutopilotMonitor.Functions.Functions string sessionId) { var tenantId = TenantHelper.IsAuthenticated(req) ? TenantHelper.GetTenantId(req) : null; - if (string.IsNullOrEmpty(tenantId)) - { - // Fall back to query parameter for cross-tenant access - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - tenantId = query["tenantId"]; - } - if (string.IsNullOrEmpty(tenantId)) { var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs index 25d53f56..1d5e7802 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs @@ -39,7 +39,7 @@ namespace AutopilotMonitor.Functions.Functions { status = "healthy", service = "Autopilot Monitor API", - version = "1.0.0-phase1", + version = "1.0.0", // TODO: Version should be dynamically retrieved from assembly info or configuration timestamp = DateTime.UtcNow }); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index 5c5e0639..5a59694d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -180,7 +180,8 @@ namespace AutopilotMonitor.Functions.Services { { "LastUpdated", config.LastUpdated }, { "UpdatedBy", config.UpdatedBy }, - { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute } + { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute }, + { "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty } }; return entity; @@ -194,7 +195,8 @@ namespace AutopilotMonitor.Functions.Services RowKey = entity.RowKey, LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", - GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100 + GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100, + PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index fe74507a..bd0adf86 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -1,9 +1,13 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using AutopilotMonitor.Shared.Models; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -38,17 +42,24 @@ namespace AutopilotMonitor.Functions.Services private readonly TableStorageService _storageService; private readonly TenantConfigurationService _tenantConfigService; private readonly UsageMetricsService _usageMetricsService; + private readonly AdminConfigurationService _adminConfigurationService; private readonly ILogger _logger; + private const string PlatformStatsAliasFileName = "platform-stats.json"; + private const string PlatformStatsAliasCacheControl = "public, max-age=300, stale-while-revalidate=86400"; + private const string PlatformStatsVersionedCacheControl = "public, max-age=31536000, immutable"; + public MaintenanceService( TableStorageService storageService, TenantConfigurationService tenantConfigService, UsageMetricsService usageMetricsService, + AdminConfigurationService adminConfigurationService, ILogger logger) { _storageService = storageService; _tenantConfigService = tenantConfigService; _usageMetricsService = usageMetricsService; + _adminConfigurationService = adminConfigurationService; _logger = logger; } @@ -479,6 +490,7 @@ namespace AutopilotMonitor.Functions.Services }; await _storageService.SavePlatformStatsAsync(stats); + await TryPublishPlatformStatsJsonAsync(stats); sw.Stop(); _logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " + @@ -490,6 +502,75 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Publishes versioned platform stats JSON + alias manifest to Blob Storage. + /// This must never fail maintenance execution. + /// + private async Task TryPublishPlatformStatsJsonAsync(PlatformStats stats) + { + try + { + var adminConfig = await _adminConfigurationService.GetConfigurationAsync(); + var containerSasUrl = adminConfig.PlatformStatsBlobSasUrl?.Trim(); + + if (string.IsNullOrWhiteSpace(containerSasUrl)) + { + _logger.LogInformation("Skipping platform stats JSON publish: PlatformStatsBlobSasUrl is not configured."); + return; + } + + var containerClient = new BlobContainerClient(new Uri(containerSasUrl)); + var generatedAtUtc = DateTime.UtcNow; + var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json"; + + var versionedPayload = new + { + totalEnrollments = stats.TotalEnrollments, + totalUsers = stats.TotalUsers, + totalTenants = stats.TotalTenants, + uniqueDeviceModels = stats.UniqueDeviceModels, + totalEventsProcessed = stats.TotalEventsProcessed, + successfulEnrollments = stats.SuccessfulEnrollments, + issuesDetected = stats.IssuesDetected, + lastFullCompute = stats.LastFullCompute, + lastUpdated = stats.LastUpdated + }; + + var aliasPayload = new + { + latest = versionedFileName, + generatedAtUtc = generatedAtUtc.ToString("o") + }; + + await UploadJsonBlobAsync(containerClient, versionedFileName, versionedPayload, PlatformStatsVersionedCacheControl); + await UploadJsonBlobAsync(containerClient, PlatformStatsAliasFileName, aliasPayload, PlatformStatsAliasCacheControl); + + _logger.LogInformation( + "Published platform stats JSON blobs: versioned={VersionedFile} and alias={AliasFile}", + versionedFileName, + PlatformStatsAliasFileName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to publish platform stats JSON to Blob Storage. Maintenance continues."); + } + } + + private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl) + { + var blobClient = containerClient.GetBlobClient(blobName); + var json = JsonConvert.SerializeObject(payload); + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + await blobClient.UploadAsync(stream, overwrite: true); + await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders + { + ContentType = "application/json; charset=utf-8", + CacheControl = cacheControl + }); + await blobClient.SetAccessTierAsync(AccessTier.Hot); + } + /// /// Calculates percentile value from sorted list /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs index 3dc18fd2..326a29e6 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs @@ -39,6 +39,12 @@ namespace AutopilotMonitor.Shared.Models /// public int GlobalRateLimitRequestsPerMinute { get; set; } = 100; + /// + /// Container SAS URL used by maintenance to publish platform stats JSON files. + /// Expected format: https://{account}.blob.core.windows.net/{container}?sv=...&sig=... + /// + public string PlatformStatsBlobSasUrl { get; set; } = string.Empty; + /// /// Creates default configuration /// @@ -50,7 +56,8 @@ namespace AutopilotMonitor.Shared.Models RowKey = "config", LastUpdated = DateTime.UtcNow, UpdatedBy = "System", - GlobalRateLimitRequestsPerMinute = 100 + GlobalRateLimitRequestsPerMinute = 100, + PlatformStatsBlobSasUrl = string.Empty }; } } diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx index 51f04e75..2347961a 100644 --- a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx @@ -12,6 +12,7 @@ interface AdminConfiguration { lastUpdated: string; updatedBy: string; globalRateLimitRequestsPerMinute: number; + platformStatsBlobSasUrl?: string; customSettings?: string; } @@ -56,6 +57,7 @@ export default function AdminConfigurationPage() { const [loadingConfig, setLoadingConfig] = useState(false); const [savingConfig, setSavingConfig] = useState(false); const [globalRateLimit, setGlobalRateLimit] = useState(100); + const [platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl] = useState(""); // Tenant Management state const [tenants, setTenants] = useState([]); @@ -122,6 +124,7 @@ export default function AdminConfigurationPage() { const data: AdminConfiguration = await response.json(); setAdminConfig(data); setGlobalRateLimit(data.globalRateLimitRequestsPerMinute); + setPlatformStatsBlobSasUrl(data.platformStatsBlobSasUrl ?? ""); } catch (err) { console.error("Error fetching admin configuration:", err); setError(err instanceof Error ? err.message : "Failed to load admin configuration"); @@ -262,6 +265,7 @@ export default function AdminConfigurationPage() { const updatedConfig: AdminConfiguration = { ...adminConfig, globalRateLimitRequestsPerMinute: globalRateLimit, + platformStatsBlobSasUrl: platformStatsBlobSasUrl.trim(), }; const token = await getAccessToken(); @@ -299,6 +303,7 @@ export default function AdminConfigurationPage() { const handleResetAdminConfig = () => { if (!adminConfig) return; setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute); + setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? ""); setSuccessMessage(null); setError(null); }; @@ -1008,6 +1013,26 @@ export default function AdminConfigurationPage() { +
+ +
+ {adminConfig && (
diff --git a/src/Web/autopilot-monitor-web/app/landing/page.tsx b/src/Web/autopilot-monitor-web/app/landing/page.tsx index 56ddff5c..571af1d6 100644 --- a/src/Web/autopilot-monitor-web/app/landing/page.tsx +++ b/src/Web/autopilot-monitor-web/app/landing/page.tsx @@ -3,15 +3,28 @@ import { useAuth } from "../../contexts/AuthContext"; import { useTheme } from "../../contexts/ThemeContext"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; -// Static platform stats - manually updated periodically -const PLATFORM_STATS = { +interface PlatformStatsManifest { + latest: string; + generatedAtUtc: string; +} + +interface PlatformStatsPayload { + totalEnrollments?: number; + totalTenants?: number; + uniqueDeviceModels?: number; +} + +const DEFAULT_PLATFORM_STATS = { totalEnrollments: 123, totalTenants: 2, uniqueDeviceModels: 1, }; +const PLATFORM_STATS_MANIFEST_URL = + process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL || "/platform-stats.json"; + const QUICK_START = [ { title: "Sign in and grant access", @@ -116,6 +129,7 @@ export default function LandingPage() { const { login, isAuthenticated, isLoading, user, isPreviewBlocked } = useAuth(); const { theme, toggleTheme } = useTheme(); const router = useRouter(); + const [platformStats, setPlatformStats] = useState(DEFAULT_PLATFORM_STATS); // Redirect after login: preview-blocked → /preview, admins → /, users → /progress useEffect(() => { @@ -130,6 +144,48 @@ export default function LandingPage() { } }, [isAuthenticated, isLoading, user, isPreviewBlocked, router]); + useEffect(() => { + let cancelled = false; + + const loadPlatformStats = async () => { + try { + const manifestResponse = await fetch(PLATFORM_STATS_MANIFEST_URL, { cache: "force-cache" }); + if (!manifestResponse.ok) { + return; + } + + const manifest = (await manifestResponse.json()) as PlatformStatsManifest; + if (!manifest?.latest) { + return; + } + + const versionedUrl = new URL(manifest.latest, manifestResponse.url).toString(); + const statsResponse = await fetch(versionedUrl, { cache: "force-cache" }); + if (!statsResponse.ok) { + return; + } + + const payload = (await statsResponse.json()) as PlatformStatsPayload; + if (cancelled) { + return; + } + + setPlatformStats({ + totalEnrollments: payload.totalEnrollments ?? DEFAULT_PLATFORM_STATS.totalEnrollments, + totalTenants: payload.totalTenants ?? DEFAULT_PLATFORM_STATS.totalTenants, + uniqueDeviceModels: payload.uniqueDeviceModels ?? DEFAULT_PLATFORM_STATS.uniqueDeviceModels, + }); + } catch { + // Keep defaults if manifest/versioned files are not reachable. + } + }; + + loadPlatformStats(); + return () => { + cancelled = true; + }; + }, []); + if (isLoading) { return (
@@ -256,11 +312,11 @@ export default function LandingPage() { {/* Platform Stats - compact inline */}
- {PLATFORM_STATS.totalEnrollments.toLocaleString()} enrollments monitored + {platformStats.totalEnrollments.toLocaleString()} enrollments monitored - {PLATFORM_STATS.totalTenants.toLocaleString()} organizations + {platformStats.totalTenants.toLocaleString()} organizations - {PLATFORM_STATS.uniqueDeviceModels.toLocaleString()} device models + {platformStats.uniqueDeviceModels.toLocaleString()} device models
{/* Product Preview Showcase */}