added platform stats

This commit is contained in:
Oliver Kieselbach
2026-02-18 12:19:52 +01:00
parent 29629d79fc
commit 19e9713a31
8 changed files with 186 additions and 18 deletions
@@ -34,6 +34,9 @@ namespace AutopilotMonitor.Functions.Functions
if (stats == null) if (stats == null)
{ {
// No stats computed yet - return zeros // 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 await response.WriteAsJsonAsync(new
{ {
totalEnrollments = 0, totalEnrollments = 0,
@@ -42,7 +45,8 @@ namespace AutopilotMonitor.Functions.Functions
uniqueDeviceModels = 0, uniqueDeviceModels = 0,
totalEventsProcessed = 0, totalEventsProcessed = 0,
successfulEnrollments = 0, successfulEnrollments = 0,
issuesDetected = 0 issuesDetected = 0,
lastUpdated = (DateTime?)null
}); });
} }
else else
@@ -33,13 +33,6 @@ namespace AutopilotMonitor.Functions.Functions
string sessionId) string sessionId)
{ {
var tenantId = TenantHelper.IsAuthenticated(req) ? TenantHelper.GetTenantId(req) : null; 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)) if (string.IsNullOrEmpty(tenantId))
{ {
var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized); var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized);
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Functions.Functions
{ {
status = "healthy", status = "healthy",
service = "Autopilot Monitor API", 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 timestamp = DateTime.UtcNow
}); });
@@ -180,7 +180,8 @@ namespace AutopilotMonitor.Functions.Services
{ {
{ "LastUpdated", config.LastUpdated }, { "LastUpdated", config.LastUpdated },
{ "UpdatedBy", config.UpdatedBy }, { "UpdatedBy", config.UpdatedBy },
{ "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute } { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute },
{ "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty }
}; };
return entity; return entity;
@@ -194,7 +195,8 @@ namespace AutopilotMonitor.Functions.Services
RowKey = entity.RowKey, RowKey = entity.RowKey,
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100 GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100,
PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty
}; };
} }
} }
@@ -1,9 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -38,17 +42,24 @@ namespace AutopilotMonitor.Functions.Services
private readonly TableStorageService _storageService; private readonly TableStorageService _storageService;
private readonly TenantConfigurationService _tenantConfigService; private readonly TenantConfigurationService _tenantConfigService;
private readonly UsageMetricsService _usageMetricsService; private readonly UsageMetricsService _usageMetricsService;
private readonly AdminConfigurationService _adminConfigurationService;
private readonly ILogger<MaintenanceService> _logger; private readonly ILogger<MaintenanceService> _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( public MaintenanceService(
TableStorageService storageService, TableStorageService storageService,
TenantConfigurationService tenantConfigService, TenantConfigurationService tenantConfigService,
UsageMetricsService usageMetricsService, UsageMetricsService usageMetricsService,
AdminConfigurationService adminConfigurationService,
ILogger<MaintenanceService> logger) ILogger<MaintenanceService> logger)
{ {
_storageService = storageService; _storageService = storageService;
_tenantConfigService = tenantConfigService; _tenantConfigService = tenantConfigService;
_usageMetricsService = usageMetricsService; _usageMetricsService = usageMetricsService;
_adminConfigurationService = adminConfigurationService;
_logger = logger; _logger = logger;
} }
@@ -479,6 +490,7 @@ namespace AutopilotMonitor.Functions.Services
}; };
await _storageService.SavePlatformStatsAsync(stats); await _storageService.SavePlatformStatsAsync(stats);
await TryPublishPlatformStatsJsonAsync(stats);
sw.Stop(); sw.Stop();
_logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " + _logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " +
@@ -490,6 +502,75 @@ namespace AutopilotMonitor.Functions.Services
} }
} }
/// <summary>
/// Publishes versioned platform stats JSON + alias manifest to Blob Storage.
/// This must never fail maintenance execution.
/// </summary>
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);
}
/// <summary> /// <summary>
/// Calculates percentile value from sorted list /// Calculates percentile value from sorted list
/// </summary> /// </summary>
@@ -39,6 +39,12 @@ namespace AutopilotMonitor.Shared.Models
/// </summary> /// </summary>
public int GlobalRateLimitRequestsPerMinute { get; set; } = 100; public int GlobalRateLimitRequestsPerMinute { get; set; } = 100;
/// <summary>
/// Container SAS URL used by maintenance to publish platform stats JSON files.
/// Expected format: https://{account}.blob.core.windows.net/{container}?sv=...&sig=...
/// </summary>
public string PlatformStatsBlobSasUrl { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Creates default configuration /// Creates default configuration
/// </summary> /// </summary>
@@ -50,7 +56,8 @@ namespace AutopilotMonitor.Shared.Models
RowKey = "config", RowKey = "config",
LastUpdated = DateTime.UtcNow, LastUpdated = DateTime.UtcNow,
UpdatedBy = "System", UpdatedBy = "System",
GlobalRateLimitRequestsPerMinute = 100 GlobalRateLimitRequestsPerMinute = 100,
PlatformStatsBlobSasUrl = string.Empty
}; };
} }
} }
@@ -12,6 +12,7 @@ interface AdminConfiguration {
lastUpdated: string; lastUpdated: string;
updatedBy: string; updatedBy: string;
globalRateLimitRequestsPerMinute: number; globalRateLimitRequestsPerMinute: number;
platformStatsBlobSasUrl?: string;
customSettings?: string; customSettings?: string;
} }
@@ -56,6 +57,7 @@ export default function AdminConfigurationPage() {
const [loadingConfig, setLoadingConfig] = useState(false); const [loadingConfig, setLoadingConfig] = useState(false);
const [savingConfig, setSavingConfig] = useState(false); const [savingConfig, setSavingConfig] = useState(false);
const [globalRateLimit, setGlobalRateLimit] = useState(100); const [globalRateLimit, setGlobalRateLimit] = useState(100);
const [platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl] = useState("");
// Tenant Management state // Tenant Management state
const [tenants, setTenants] = useState<TenantConfiguration[]>([]); const [tenants, setTenants] = useState<TenantConfiguration[]>([]);
@@ -122,6 +124,7 @@ export default function AdminConfigurationPage() {
const data: AdminConfiguration = await response.json(); const data: AdminConfiguration = await response.json();
setAdminConfig(data); setAdminConfig(data);
setGlobalRateLimit(data.globalRateLimitRequestsPerMinute); setGlobalRateLimit(data.globalRateLimitRequestsPerMinute);
setPlatformStatsBlobSasUrl(data.platformStatsBlobSasUrl ?? "");
} catch (err) { } catch (err) {
console.error("Error fetching admin configuration:", err); console.error("Error fetching admin configuration:", err);
setError(err instanceof Error ? err.message : "Failed to load admin configuration"); setError(err instanceof Error ? err.message : "Failed to load admin configuration");
@@ -262,6 +265,7 @@ export default function AdminConfigurationPage() {
const updatedConfig: AdminConfiguration = { const updatedConfig: AdminConfiguration = {
...adminConfig, ...adminConfig,
globalRateLimitRequestsPerMinute: globalRateLimit, globalRateLimitRequestsPerMinute: globalRateLimit,
platformStatsBlobSasUrl: platformStatsBlobSasUrl.trim(),
}; };
const token = await getAccessToken(); const token = await getAccessToken();
@@ -299,6 +303,7 @@ export default function AdminConfigurationPage() {
const handleResetAdminConfig = () => { const handleResetAdminConfig = () => {
if (!adminConfig) return; if (!adminConfig) return;
setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute); setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute);
setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? "");
setSuccessMessage(null); setSuccessMessage(null);
setError(null); setError(null);
}; };
@@ -1008,6 +1013,26 @@ export default function AdminConfigurationPage() {
</label> </label>
</div> </div>
<div>
<label className="block">
<span className="text-gray-700 font-medium">Platform Stats Blob Container SAS URL</span>
<p className="text-sm text-gray-600 mb-2">
Maintenance publishes two files into this container:
<code className="ml-1 mr-1 text-xs">platform-stats.json</code>
and
<code className="ml-1 text-xs">platform-stats.YYYY-MM-DD.json</code>.
The upload is best-effort and does not fail the maintenance run.
</p>
<input
type="url"
value={platformStatsBlobSasUrl}
onChange={(e) => setPlatformStatsBlobSasUrl(e.target.value)}
placeholder="https://storageaccount.blob.core.windows.net/publicstats?sv=...&sig=..."
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
/>
</label>
</div>
{adminConfig && ( {adminConfig && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start space-x-2"> <div className="flex items-start space-x-2">
@@ -3,15 +3,28 @@
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { useTheme } from "../../contexts/ThemeContext"; import { useTheme } from "../../contexts/ThemeContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect, useState } from "react";
// Static platform stats - manually updated periodically interface PlatformStatsManifest {
const PLATFORM_STATS = { latest: string;
generatedAtUtc: string;
}
interface PlatformStatsPayload {
totalEnrollments?: number;
totalTenants?: number;
uniqueDeviceModels?: number;
}
const DEFAULT_PLATFORM_STATS = {
totalEnrollments: 123, totalEnrollments: 123,
totalTenants: 2, totalTenants: 2,
uniqueDeviceModels: 1, uniqueDeviceModels: 1,
}; };
const PLATFORM_STATS_MANIFEST_URL =
process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL || "/platform-stats.json";
const QUICK_START = [ const QUICK_START = [
{ {
title: "Sign in and grant access", title: "Sign in and grant access",
@@ -116,6 +129,7 @@ export default function LandingPage() {
const { login, isAuthenticated, isLoading, user, isPreviewBlocked } = useAuth(); const { login, isAuthenticated, isLoading, user, isPreviewBlocked } = useAuth();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const router = useRouter(); const router = useRouter();
const [platformStats, setPlatformStats] = useState(DEFAULT_PLATFORM_STATS);
// Redirect after login: preview-blocked → /preview, admins → /, users → /progress // Redirect after login: preview-blocked → /preview, admins → /, users → /progress
useEffect(() => { useEffect(() => {
@@ -130,6 +144,48 @@ export default function LandingPage() {
} }
}, [isAuthenticated, isLoading, user, isPreviewBlocked, router]); }, [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) { if (isLoading) {
return ( return (
<div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center"> <div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center">
@@ -256,11 +312,11 @@ export default function LandingPage() {
{/* Platform Stats - compact inline */} {/* Platform Stats - compact inline */}
<div className="mt-10 flex items-center justify-center gap-6 text-xs text-gray-400"> <div className="mt-10 flex items-center justify-center gap-6 text-xs text-gray-400">
<span><span className="font-semibold text-gray-600">{PLATFORM_STATS.totalEnrollments.toLocaleString()}</span> enrollments monitored</span> <span><span className="font-semibold text-gray-600">{platformStats.totalEnrollments.toLocaleString()}</span> enrollments monitored</span>
<span className="w-px h-3 bg-gray-300" /> <span className="w-px h-3 bg-gray-300" />
<span><span className="font-semibold text-gray-600">{PLATFORM_STATS.totalTenants.toLocaleString()}</span> organizations</span> <span><span className="font-semibold text-gray-600">{platformStats.totalTenants.toLocaleString()}</span> organizations</span>
<span className="w-px h-3 bg-gray-300" /> <span className="w-px h-3 bg-gray-300" />
<span><span className="font-semibold text-gray-600">{PLATFORM_STATS.uniqueDeviceModels.toLocaleString()}</span> device models</span> <span><span className="font-semibold text-gray-600">{platformStats.uniqueDeviceModels.toLocaleString()}</span> device models</span>
</div> </div>
{/* Product Preview Showcase */} {/* Product Preview Showcase */}