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)
|
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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user