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)
{
// 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
@@ -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);
@@ -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
});
@@ -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
};
}
}
@@ -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<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(
TableStorageService storageService,
TenantConfigurationService tenantConfigService,
UsageMetricsService usageMetricsService,
AdminConfigurationService adminConfigurationService,
ILogger<MaintenanceService> 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
}
}
/// <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>
/// Calculates percentile value from sorted list
/// </summary>
@@ -39,6 +39,12 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
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>
/// Creates default configuration
/// </summary>
@@ -50,7 +56,8 @@ namespace AutopilotMonitor.Shared.Models
RowKey = "config",
LastUpdated = DateTime.UtcNow,
UpdatedBy = "System",
GlobalRateLimitRequestsPerMinute = 100
GlobalRateLimitRequestsPerMinute = 100,
PlatformStatsBlobSasUrl = string.Empty
};
}
}
@@ -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<TenantConfiguration[]>([]);
@@ -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() {
</label>
</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 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start space-x-2">
@@ -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 (
<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 */}
<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><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><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>
{/* Product Preview Showcase */}