mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
added platform stats
This commit is contained in:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user