mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Landing page bakes the platform stats of the deploy into its static HTML; browsers then swap in the live numbers
Crawlers and LLM fetchers run no JS and saw only the skeleton of the stats band. app/page.tsx now awaits one read of the public stats blob during next build (lib/platformStats.ts: manifest, then the versioned file it names) and renders the numbers into the export. StatsBand starts from that snapshot and replaces it with the live numbers; a failed live read keeps the baked numbers instead of hiding the band. The read is awaited at page level, not under Suspense: React moves a resolved boundary of a page this size into a hidden div at the end of the body. It uses no-store (route force-static), so Next's fetch cache cannot hand a later build the previous numbers. Fail-soft: without a snapshot the page ships the skeleton and loads live as before, and deploy-web.yml warns. The browser now revalidates both files (no-cache instead of force-cache): the backend rewrites the per-day file every 2 hours under immutable, so a copy cached earlier that day would have made the numbers drop after load. The page body moved to components/landing/LandingPage.tsx, which the marketing guards render synchronously; StatsBand left the .json() ratchet baseline; the sitemap lastmod of / includes the body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
This commit is contained in:
@@ -108,6 +108,8 @@ jobs:
|
||||
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
# Browsers load the live stats through it, and the build reads it once to bake the
|
||||
# numbers of this moment into the landing page's static HTML (lib/platformStats.ts).
|
||||
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
|
||||
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
@@ -121,6 +123,9 @@ jobs:
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
|
||||
# Fail-soft like the star count: browsers still load the stats live; only crawlers and
|
||||
# LLM fetchers, which run no JS, see no numbers until the next deploy.
|
||||
grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy"
|
||||
|
||||
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
|
||||
- name: Package export
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
import { AuthGate } from "../components/landing/AuthGate";
|
||||
import { LandingNavbar } from "../components/landing/LandingNavbar";
|
||||
import { Hero } from "../components/landing/Hero";
|
||||
import { StatsBand } from "../components/landing/StatsBand";
|
||||
import { JustAsk } from "../components/landing/JustAsk";
|
||||
import { Story } from "../components/landing/Story";
|
||||
import { CapabilitiesStrip } from "../components/landing/CapabilitiesStrip";
|
||||
import { Comparison } from "../components/landing/Comparison";
|
||||
import { HowItWorks } from "../components/landing/HowItWorks";
|
||||
import { Testimonials } from "../components/landing/Testimonials";
|
||||
import { FinalCta } from "../components/landing/FinalCta";
|
||||
import { SiteFooter } from "../components/SiteFooter";
|
||||
import { MarketingTracker } from "../components/MarketingTracker";
|
||||
import { LandingPage } from "../components/landing/LandingPage";
|
||||
import { loadPlatformStatsSnapshot } from "../lib/platformStats";
|
||||
|
||||
// The stats read below runs once per `next build` and bypasses Next's fetch cache (`no-store`,
|
||||
// see lib/platformStats.ts). force-static keeps that legal in the static export; the landing
|
||||
// tree reads no request-time API (cookies, headers, search params) that it would blank out.
|
||||
export const dynamic = "force-static";
|
||||
|
||||
/**
|
||||
* Landing page v2 — a scroll story of one enrollment.
|
||||
* All theming goes through the lp-* tokens (globals.css); sections live
|
||||
* in components/landing/. Auth behavior (AuthGate redirect, LoginButton
|
||||
* portal handoff) is unchanged from v1.
|
||||
* The landing page with the platform stats of the build baked into its static HTML, so
|
||||
* crawlers and LLM fetchers that run no JS see the numbers too; browsers then load the live
|
||||
* ones (components/landing/StatsBand.tsx). Awaited here, not under a Suspense boundary: React
|
||||
* would move a resolved boundary of a page this size into a hidden div at the end of the body.
|
||||
*/
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
|
||||
{/* Client component: handles auth redirect + loading overlay */}
|
||||
<AuthGate />
|
||||
<MarketingTracker page="landing">
|
||||
<LandingNavbar />
|
||||
<Hero />
|
||||
<StatsBand />
|
||||
<JustAsk />
|
||||
<Story />
|
||||
<CapabilitiesStrip />
|
||||
<Comparison />
|
||||
<HowItWorks />
|
||||
<Testimonials />
|
||||
<FinalCta />
|
||||
<SiteFooter />
|
||||
</MarketingTracker>
|
||||
</div>
|
||||
);
|
||||
export default async function Page() {
|
||||
return <LandingPage statsSnapshot={await loadPlatformStatsSnapshot()} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { prerender } from "react-dom/static";
|
||||
|
||||
// LandingNavbar and LoginButton read the auth context; the public pages render signed out.
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => ({ login: async () => undefined, isAuthenticated: false, user: null, getAccessToken: async () => null }),
|
||||
}));
|
||||
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
|
||||
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
|
||||
|
||||
const { SNAPSHOT } = vi.hoisted(() => ({
|
||||
SNAPSHOT: {
|
||||
items: [
|
||||
{ label: "enrollments monitored", value: "12,481" },
|
||||
{ label: "events processed", value: "8.3M" },
|
||||
],
|
||||
asOf: "2026-10-02T14:00:03.1234567Z",
|
||||
},
|
||||
}));
|
||||
// The blob read itself is covered in lib/__tests__/platformStats.test.ts.
|
||||
vi.mock("@/lib/platformStats", async importOriginal => ({
|
||||
...(await importOriginal<typeof import("@/lib/platformStats")>()),
|
||||
loadPlatformStatsSnapshot: async () => SNAPSHOT,
|
||||
}));
|
||||
|
||||
import LandingRoute from "@/app/page";
|
||||
import { StatsBand } from "@/components/landing/StatsBand";
|
||||
|
||||
/**
|
||||
* Crawlers and LLM fetchers run no JS: the platform stats only reach them when the build renders
|
||||
* the numbers into the landing page's HTML (lib/platformStats.ts, app/page.tsx).
|
||||
*/
|
||||
describe("platform stats in the landing page's static HTML", () => {
|
||||
it("the band renders the baked numbers and marks the snapshot", () => {
|
||||
const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: SNAPSHOT }));
|
||||
expect(html).toContain(">12,481<");
|
||||
expect(html).toContain(">enrollments monitored<");
|
||||
expect(html).toContain(">8.3M<");
|
||||
expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`);
|
||||
expect(html).not.toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("without a snapshot the band shows the loading skeleton and no marker", () => {
|
||||
const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: null }));
|
||||
expect(html).toContain('data-track-section="stats"');
|
||||
expect(html).toContain("animate-pulse");
|
||||
expect(html).not.toContain("data-stats-snapshot");
|
||||
});
|
||||
|
||||
// A Suspense boundary around the read would still resolve at build time, but React moves a
|
||||
// resolved boundary of a page this size into a hidden div at the end of the body.
|
||||
it("the page renders the numbers in place, not in a hidden streaming segment", async () => {
|
||||
const { prelude } = await prerender(createElement(LandingRoute));
|
||||
const html = await new Response(prelude).text();
|
||||
|
||||
const bandStart = html.indexOf('data-track-section="stats"');
|
||||
const nextSection = html.indexOf('data-track-section="just_ask"');
|
||||
const figure = html.indexOf(">12,481<");
|
||||
expect(bandStart).toBeGreaterThan(-1);
|
||||
expect(figure).toBeGreaterThan(bandStart);
|
||||
expect(figure).toBeLessThan(nextSection);
|
||||
expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`);
|
||||
expect(html).not.toMatch(/<div hidden id="S:/);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ vi.mock("@/contexts/AuthContext", () => ({
|
||||
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
|
||||
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
|
||||
|
||||
import LandingPage from "@/app/page";
|
||||
import { LandingPage } from "@/components/landing/LandingPage";
|
||||
import GetStartedPage from "@/app/get-started/page";
|
||||
import AiPage from "@/app/ai/page";
|
||||
import AboutPage from "@/app/about/page";
|
||||
@@ -25,7 +25,8 @@ import TermsPage from "@/app/terms/page";
|
||||
*/
|
||||
|
||||
const PAGES: Record<string, ComponentType> = {
|
||||
landing: LandingPage,
|
||||
// app/page.tsx only awaits the build-time stats and renders this body; the sync renderer cannot await.
|
||||
landing: () => createElement(LandingPage, { statsSnapshot: null }),
|
||||
"get-started": GetStartedPage,
|
||||
ai: AiPage,
|
||||
about: AboutPage,
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("@/contexts/AuthContext", () => ({
|
||||
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
|
||||
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
|
||||
|
||||
import LandingPage from "@/app/page";
|
||||
import { LandingPage } from "@/components/landing/LandingPage";
|
||||
import GetStartedPage from "@/app/get-started/page";
|
||||
import AiPage from "@/app/ai/page";
|
||||
import robots from "@/app/robots";
|
||||
@@ -27,6 +27,8 @@ import { sitePageUrl } from "@/utils/config";
|
||||
*/
|
||||
|
||||
const render = (Page: ComponentType) => renderToStaticMarkup(createElement(Page));
|
||||
// app/page.tsx only awaits the build-time stats and renders this body; the sync renderer cannot await.
|
||||
const Landing = () => createElement(LandingPage, { statsSnapshot: null });
|
||||
const trackIds = (html: string) => [...html.matchAll(/data-track="([^"]+)"/g)].map(m => m[1]);
|
||||
const sectionIds = (html: string) => [...html.matchAll(/data-track-section="([^"]+)"/g)].map(m => m[1]);
|
||||
const untracked = (html: string) =>
|
||||
@@ -37,7 +39,7 @@ const untracked = (html: string) =>
|
||||
const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [
|
||||
{
|
||||
name: "landing",
|
||||
Page: LandingPage,
|
||||
Page: Landing,
|
||||
sections: ["hero", "stats", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"],
|
||||
},
|
||||
{ name: "get-started", Page: GetStartedPage, sections: ["steps", "signup"] },
|
||||
@@ -60,7 +62,7 @@ describe("marketing page tracking", () => {
|
||||
}
|
||||
|
||||
it("the landing links to the AI page from the navbar, Then just ask and the footer", () => {
|
||||
const ids = trackIds(render(LandingPage));
|
||||
const ids = trackIds(render(Landing));
|
||||
expect(ids).toEqual(expect.arrayContaining(["nav_ai", "just_ask_ai", "footer_ai"]));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AuthGate } from "./AuthGate";
|
||||
import { LandingNavbar } from "./LandingNavbar";
|
||||
import { Hero } from "./Hero";
|
||||
import { StatsBand } from "./StatsBand";
|
||||
import { JustAsk } from "./JustAsk";
|
||||
import { Story } from "./Story";
|
||||
import { CapabilitiesStrip } from "./CapabilitiesStrip";
|
||||
import { Comparison } from "./Comparison";
|
||||
import { HowItWorks } from "./HowItWorks";
|
||||
import { Testimonials } from "./Testimonials";
|
||||
import { FinalCta } from "./FinalCta";
|
||||
import { SiteFooter } from "../SiteFooter";
|
||||
import { MarketingTracker } from "../MarketingTracker";
|
||||
import type { PlatformStatsSnapshot } from "@/lib/platformStats";
|
||||
|
||||
/**
|
||||
* Landing page v2 — a scroll story of one enrollment.
|
||||
* All theming goes through the lp-* tokens (globals.css); sections live
|
||||
* in components/landing/. Auth behavior (AuthGate redirect, LoginButton
|
||||
* portal handoff) is unchanged from v1.
|
||||
*
|
||||
* Synchronous on purpose: app/page.tsx awaits the build-time stats and hands
|
||||
* them in, and the marketing guard tests render this body with the sync
|
||||
* renderToStaticMarkup.
|
||||
*/
|
||||
export function LandingPage({ statsSnapshot }: { statsSnapshot: PlatformStatsSnapshot | null }) {
|
||||
return (
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
|
||||
{/* Client component: handles auth redirect + loading overlay */}
|
||||
<AuthGate />
|
||||
<MarketingTracker page="landing">
|
||||
<LandingNavbar />
|
||||
<Hero />
|
||||
<StatsBand snapshot={statsSnapshot} />
|
||||
<JustAsk />
|
||||
<Story />
|
||||
<CapabilitiesStrip />
|
||||
<Comparison />
|
||||
<HowItWorks />
|
||||
<Testimonials />
|
||||
<FinalCta />
|
||||
<SiteFooter />
|
||||
</MarketingTracker>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface PlatformStatsManifest {
|
||||
latest: string;
|
||||
generatedAtUtc: string;
|
||||
}
|
||||
|
||||
interface PlatformStatsPayload {
|
||||
totalEnrollments?: number;
|
||||
totalUsers?: number;
|
||||
totalTenants?: number;
|
||||
totalSignedUpTenants?: number;
|
||||
uniqueDeviceModels?: number;
|
||||
totalEventsProcessed?: number;
|
||||
successfulEnrollments?: number;
|
||||
issuesDetected?: number;
|
||||
lastFullCompute?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
function resolvePlatformStatsManifestUrl(rawUrl?: string): string {
|
||||
const trimmed = rawUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return "/platform-stats.json";
|
||||
}
|
||||
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
const withoutHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
|
||||
const hash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
|
||||
|
||||
const queryIndex = withoutHash.indexOf("?");
|
||||
const basePath = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
|
||||
const query = queryIndex >= 0 ? withoutHash.slice(queryIndex) : "";
|
||||
|
||||
if (/\.json$/i.test(basePath)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
||||
const manifestPath = `${normalizedBasePath}/platform-stats.json`;
|
||||
return `${manifestPath}${query}${hash}`;
|
||||
}
|
||||
|
||||
const PLATFORM_STATS_MANIFEST_URL =
|
||||
resolvePlatformStatsManifestUrl(process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL);
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 8,341,206 → "8.3M"; smaller values keep their grouped form. */
|
||||
function compact(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
import {
|
||||
fetchLivePlatformStats,
|
||||
toStatItems,
|
||||
type PlatformStatsSnapshot,
|
||||
type StatItem,
|
||||
} from "@/lib/platformStats";
|
||||
|
||||
/**
|
||||
* Sample values for local development only, where no stats blob is
|
||||
@@ -71,81 +22,51 @@ const DEV_SAMPLE_STATS: StatItem[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Full-bleed platform stats band under the hero shot. Skeleton while
|
||||
* loading; on fetch failure it shows dev sample data locally and
|
||||
* disappears entirely in production (never an endless skeleton, never
|
||||
* fake numbers).
|
||||
* Full-bleed platform stats band under the hero shot. Starts with the
|
||||
* numbers the build baked in (`snapshot`, lib/platformStats.ts), so the
|
||||
* static HTML carries them for crawlers and LLM fetchers, then swaps in the
|
||||
* live numbers. A failed live read keeps the baked numbers; without either,
|
||||
* it shows dev sample data locally and disappears entirely in production
|
||||
* (never an endless skeleton, never fake numbers). The skeleton only shows
|
||||
* while a page built without a snapshot loads.
|
||||
*/
|
||||
export function StatsBand() {
|
||||
const [stats, setStats] = useState<StatItem[] | null>(null);
|
||||
export function StatsBand({ snapshot }: { snapshot: PlatformStatsSnapshot | null }) {
|
||||
const [live, setLive] = useState<StatItem[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fail = () => {
|
||||
const loadLiveStats = async () => {
|
||||
const payload = await fetchLivePlatformStats();
|
||||
if (cancelled) return;
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
setStats(DEV_SAMPLE_STATS);
|
||||
const items = payload ? toStatItems(payload) : [];
|
||||
if (items.length > 0) {
|
||||
setLive(items);
|
||||
} else {
|
||||
setFailed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlatformStats = async () => {
|
||||
try {
|
||||
const manifestResponse = await fetch(PLATFORM_STATS_MANIFEST_URL, { cache: "no-store" });
|
||||
if (!manifestResponse.ok) return fail();
|
||||
|
||||
const manifest = (await manifestResponse.json()) as PlatformStatsManifest;
|
||||
if (!manifest?.latest) return fail();
|
||||
|
||||
const versionedUrl = new URL(manifest.latest, manifestResponse.url).toString();
|
||||
const statsResponse = await fetch(versionedUrl, { cache: "force-cache" });
|
||||
if (!statsResponse.ok) return fail();
|
||||
|
||||
const payload = (await statsResponse.json()) as PlatformStatsPayload;
|
||||
if (cancelled) return;
|
||||
|
||||
const items: StatItem[] = [];
|
||||
if (payload.totalEnrollments) {
|
||||
items.push({ label: "enrollments monitored", value: payload.totalEnrollments.toLocaleString("en-US") });
|
||||
}
|
||||
if (payload.issuesDetected) {
|
||||
items.push({ label: "issues detected", value: payload.issuesDetected.toLocaleString("en-US") });
|
||||
}
|
||||
if (payload.totalSignedUpTenants) {
|
||||
items.push({ label: "organisations", value: payload.totalSignedUpTenants.toLocaleString("en-US") });
|
||||
}
|
||||
if (payload.uniqueDeviceModels) {
|
||||
items.push({ label: "device models", value: payload.uniqueDeviceModels.toLocaleString("en-US") });
|
||||
}
|
||||
if (payload.totalEventsProcessed) {
|
||||
items.push({ label: "events processed", value: compact(payload.totalEventsProcessed) });
|
||||
}
|
||||
if (items.length > 0) {
|
||||
setStats(items);
|
||||
} else {
|
||||
fail();
|
||||
}
|
||||
} catch {
|
||||
// The band is decorative and must never break the page.
|
||||
fail();
|
||||
}
|
||||
};
|
||||
|
||||
loadPlatformStats();
|
||||
loadLiveStats();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (failed) {
|
||||
const devFallback = failed && process.env.NODE_ENV === "development" ? DEV_SAMPLE_STATS : null;
|
||||
const stats = live ?? snapshot?.items ?? devFallback;
|
||||
if (!stats && failed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-track-section="stats" className="border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]">
|
||||
<section
|
||||
data-track-section="stats"
|
||||
// Which build snapshot the static HTML carries; deploy-web.yml warns when it is missing.
|
||||
data-stats-snapshot={snapshot ? (snapshot.asOf ?? "") : undefined}
|
||||
className="border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-y-6 py-8 lg:divide-x lg:divide-[var(--lp-line)]">
|
||||
{(stats ?? Array.from({ length: 5 }, () => null)).map((item, i) =>
|
||||
item ? (
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
/** Files that keep a raw site for a stated reason; they never leave the baseline. */
|
||||
export const PERMANENT: Record<string, string> = {
|
||||
"contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists: raw fetch by design",
|
||||
"components/landing/StatsBand.tsx": "unauthenticated public blob manifest, not the API",
|
||||
"app/health-check/page.tsx": "/version.json of the SWA itself, not the API",
|
||||
"app/dashboard/hooks/deleteSessionResponse.ts": "safeJson on the 202 Response of the delete-cascade classifier (refusals arrive as ApiError)",
|
||||
"app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace); the access check rethrows it as a non-outcome",
|
||||
@@ -33,7 +32,6 @@ export const DEDUPEDAUTHFETCH_BASELINE: Record<string, number> = {
|
||||
export const JSONPARSE_BASELINE: Record<string, number> = {
|
||||
"app/dashboard/hooks/deleteSessionResponse.ts": 1,
|
||||
"app/health-check/page.tsx": 1,
|
||||
"components/landing/StatsBand.tsx": 2,
|
||||
"contexts/AuthContext.tsx": 2,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
import {
|
||||
fetchLivePlatformStats,
|
||||
loadPlatformStatsSnapshot,
|
||||
resolvePlatformStatsManifestUrl,
|
||||
toStatItems,
|
||||
} from "../platformStats";
|
||||
|
||||
const CONTAINER = "https://stats.example.test/publicstats";
|
||||
const MANIFEST_URL = `${CONTAINER}/platform-stats.json`;
|
||||
const PAYLOAD_URL = `${CONTAINER}/platform-stats.2026-10-02.json`;
|
||||
|
||||
/** What the backend publishes (MaintenanceService.TryPublishPlatformStatsJsonAsync). */
|
||||
const MANIFEST = { latest: "platform-stats.2026-10-02.json", generatedAtUtc: "2026-10-02T14:00:05.1234567Z" };
|
||||
const PAYLOAD = {
|
||||
totalEnrollments: 12481,
|
||||
totalUsers: 310,
|
||||
totalTenants: 92,
|
||||
totalSignedUpTenants: 87,
|
||||
uniqueDeviceModels: 214,
|
||||
totalEventsProcessed: 8341206,
|
||||
successfulEnrollments: 11020,
|
||||
issuesDetected: 1847,
|
||||
lastFullCompute: "2026-10-02T14:00:03.1234567Z",
|
||||
lastUpdated: "2026-10-02T14:00:03.1234567Z",
|
||||
};
|
||||
|
||||
const BAND = [
|
||||
{ label: "enrollments monitored", value: "12,481" },
|
||||
{ label: "issues detected", value: "1,847" },
|
||||
{ label: "organisations", value: "87" },
|
||||
{ label: "device models", value: "214" },
|
||||
{ label: "events processed", value: "8.3M" },
|
||||
];
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
/** Answers by URL; anything not listed is a 404. */
|
||||
function serve(routes: Record<string, () => Response>) {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async input =>
|
||||
routes[input]?.() ?? new Response("not found", { status: 404 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
const BOTH_FILES = { [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json(PAYLOAD) };
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("resolvePlatformStatsManifestUrl", () => {
|
||||
it("falls back to the same-origin manifest without a configured URL", () => {
|
||||
expect([undefined, "", " "].map(resolvePlatformStatsManifestUrl)).toEqual(Array(3).fill("/platform-stats.json"));
|
||||
});
|
||||
|
||||
it("appends the manifest name to a container URL, with or without a trailing slash", () => {
|
||||
expect(resolvePlatformStatsManifestUrl(CONTAINER)).toBe(MANIFEST_URL);
|
||||
expect(resolvePlatformStatsManifestUrl(`${CONTAINER}/`)).toBe(MANIFEST_URL);
|
||||
});
|
||||
|
||||
it("keeps a URL that already names a JSON file", () => {
|
||||
expect(resolvePlatformStatsManifestUrl(MANIFEST_URL)).toBe(MANIFEST_URL);
|
||||
expect(resolvePlatformStatsManifestUrl(`${CONTAINER}/custom.JSON`)).toBe(`${CONTAINER}/custom.JSON`);
|
||||
});
|
||||
|
||||
it("keeps query and hash behind the appended manifest name", () => {
|
||||
expect(resolvePlatformStatsManifestUrl(`${CONTAINER}?v=1#x`)).toBe(`${MANIFEST_URL}?v=1#x`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStatItems", () => {
|
||||
it("maps the band's figures in display order", () => {
|
||||
expect(toStatItems(PAYLOAD)).toEqual(BAND);
|
||||
});
|
||||
|
||||
it("leaves out figures that are missing, zero, negative or not a finite number", () => {
|
||||
const payload = {
|
||||
totalEnrollments: 0,
|
||||
issuesDetected: -5,
|
||||
totalSignedUpTenants: Number.POSITIVE_INFINITY,
|
||||
uniqueDeviceModels: "214" as unknown as number,
|
||||
totalEventsProcessed: 999_999,
|
||||
};
|
||||
expect(toStatItems(payload)).toEqual([{ label: "events processed", value: "999,999" }]);
|
||||
expect(toStatItems({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchLivePlatformStats", () => {
|
||||
it("reads the manifest, then the file it names, revalidating both with the blob", async () => {
|
||||
const fetchMock = serve(BOTH_FILES);
|
||||
|
||||
expect(await fetchLivePlatformStats(MANIFEST_URL)).toEqual(PAYLOAD);
|
||||
expect(fetchMock.mock.calls).toEqual([
|
||||
[MANIFEST_URL, { cache: "no-cache" }],
|
||||
[PAYLOAD_URL, { cache: "no-cache" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves the versioned file against the manifest as served, after redirects", async () => {
|
||||
const redirected = json(MANIFEST);
|
||||
Object.defineProperty(redirected, "url", { value: "https://stats.example.test/v2/platform-stats.json" });
|
||||
const fetchMock = serve({
|
||||
[MANIFEST_URL]: () => redirected,
|
||||
"https://stats.example.test/v2/platform-stats.2026-10-02.json": () => json(PAYLOAD),
|
||||
});
|
||||
|
||||
expect(await fetchLivePlatformStats(MANIFEST_URL)).toEqual(PAYLOAD);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const failures: [string, Record<string, () => Response>][] = [
|
||||
["the manifest is missing", { [PAYLOAD_URL]: () => json(PAYLOAD) }],
|
||||
["the manifest names no file", { [MANIFEST_URL]: () => json({ generatedAtUtc: MANIFEST.generatedAtUtc }) }],
|
||||
["the manifest is not JSON", { [MANIFEST_URL]: () => new Response("<html></html>") }],
|
||||
["the manifest names a file on another origin", { [MANIFEST_URL]: () => json({ latest: "https://elsewhere.example.test/stats.json" }) }],
|
||||
["the versioned file is missing", { [MANIFEST_URL]: () => json(MANIFEST) }],
|
||||
["the versioned file holds no object", { [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json(42) }],
|
||||
];
|
||||
for (const [name, routes] of failures) {
|
||||
it(`returns null when ${name}`, async () => {
|
||||
serve(routes);
|
||||
expect(await fetchLivePlatformStats(MANIFEST_URL)).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
it("returns null when the network fails", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
expect(await fetchLivePlatformStats(MANIFEST_URL)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadPlatformStatsSnapshot", () => {
|
||||
let log: MockInstance<typeof console.log>;
|
||||
let warn: MockInstance<typeof console.warn>;
|
||||
beforeEach(() => {
|
||||
log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it("bakes nothing and sends no request without an absolute URL (local and CI builds)", async () => {
|
||||
const fetchMock = serve(BOTH_FILES);
|
||||
|
||||
expect(await loadPlatformStatsSnapshot("/platform-stats.json")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the band's items and the stats timestamp, read past Next's fetch cache within a deadline", async () => {
|
||||
const fetchMock = serve(BOTH_FILES);
|
||||
|
||||
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toEqual({ items: BAND, asOf: PAYLOAD.lastUpdated });
|
||||
const inits = fetchMock.mock.calls.map(([, init]) => init);
|
||||
expect(inits).toHaveLength(2);
|
||||
for (const init of inits) {
|
||||
expect(init?.cache).toBe("no-store");
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
expect(inits[0]?.signal).toBe(inits[1]?.signal);
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining(PAYLOAD.lastUpdated));
|
||||
});
|
||||
|
||||
it("fails soft with the reason in the build log when the blob does not answer", async () => {
|
||||
serve({ [MANIFEST_URL]: () => new Response("unavailable", { status: 503 }) });
|
||||
|
||||
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("HTTP 503"));
|
||||
});
|
||||
|
||||
it("names the network cause behind Node's generic fetch failure", async () => {
|
||||
const failure = new TypeError("fetch failed", { cause: new Error("getaddrinfo ENOTFOUND stats.example.test") });
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(failure));
|
||||
|
||||
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("fetch failed (getaddrinfo ENOTFOUND stats.example.test)"));
|
||||
});
|
||||
|
||||
it("bakes nothing when the file holds no positive figure", async () => {
|
||||
serve({ [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json({ ...PAYLOAD, totalEnrollments: 0, issuesDetected: 0, totalSignedUpTenants: 0, uniqueDeviceModels: 0, totalEventsProcessed: 0 }) });
|
||||
|
||||
expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("no positive figure"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { PlatformStats } from "@/utils/wire-types.generated";
|
||||
|
||||
/**
|
||||
* The landing page's platform stats. A public blob container holds a manifest
|
||||
* (`platform-stats.json`, rewritten by the backend maintenance run every 2 hours) that names the
|
||||
* versioned file with the numbers (`latest`, relative to the manifest).
|
||||
*
|
||||
* Read twice: once by `next build`, which bakes the numbers of that moment into the static HTML
|
||||
* (crawlers and LLM fetchers run no JS and would otherwise see only a skeleton), and again by
|
||||
* every browser, which swaps in the live numbers (components/landing/StatsBand.tsx). Both reads
|
||||
* share the URL, fetch and formatting rules here.
|
||||
*/
|
||||
|
||||
interface PlatformStatsManifest {
|
||||
latest: string;
|
||||
generatedAtUtc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The versioned file. The backend writes it field by field under the names of the wire type
|
||||
* PlatformStats; every field stays optional because the body is parsed, not trusted.
|
||||
*/
|
||||
export type PlatformStatsPayload = Partial<PlatformStats>;
|
||||
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** The numbers baked into the static HTML at build time. */
|
||||
export interface PlatformStatsSnapshot {
|
||||
items: StatItem[];
|
||||
/** When the backend computed them (the payload's lastUpdated): page marker and build log. */
|
||||
asOf: string | null;
|
||||
}
|
||||
|
||||
export function resolvePlatformStatsManifestUrl(rawUrl?: string): string {
|
||||
const trimmed = rawUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return "/platform-stats.json";
|
||||
}
|
||||
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
const withoutHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
|
||||
const hash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
|
||||
|
||||
const queryIndex = withoutHash.indexOf("?");
|
||||
const basePath = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
|
||||
const query = queryIndex >= 0 ? withoutHash.slice(queryIndex) : "";
|
||||
|
||||
if (/\.json$/i.test(basePath)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
||||
const manifestPath = `${normalizedBasePath}/platform-stats.json`;
|
||||
return `${manifestPath}${query}${hash}`;
|
||||
}
|
||||
|
||||
export const PLATFORM_STATS_MANIFEST_URL =
|
||||
resolvePlatformStatsManifestUrl(process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL);
|
||||
|
||||
/** Budget for both build-time requests together: a slow blob must not stall the deploy. */
|
||||
const BUILD_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** 8,341,206 → "8.3M"; smaller values keep their grouped form. */
|
||||
function compact(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function grouped(n: number): string {
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
/** The band's figures in display order. */
|
||||
const STAT_FIELDS: { field: keyof PlatformStats; label: string; format: (n: number) => string }[] = [
|
||||
{ field: "totalEnrollments", label: "enrollments monitored", format: grouped },
|
||||
{ field: "issuesDetected", label: "issues detected", format: grouped },
|
||||
{ field: "totalSignedUpTenants", label: "organisations", format: grouped },
|
||||
{ field: "uniqueDeviceModels", label: "device models", format: grouped },
|
||||
{ field: "totalEventsProcessed", label: "events processed", format: compact },
|
||||
];
|
||||
|
||||
/**
|
||||
* The band's items. Only finite positive numbers count: the values end up in the static HTML,
|
||||
* and a zero means "not computed yet", not a figure to publish.
|
||||
*/
|
||||
export function toStatItems(payload: PlatformStatsPayload): StatItem[] {
|
||||
return STAT_FIELDS.flatMap(({ field, label, format }) => {
|
||||
const value = payload[field];
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? [{ label, value: format(value) }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Manifest, then the file it names. Throws with the reason; the callers decide what to report. */
|
||||
async function readPlatformStats(manifestUrl: string, init: RequestInit): Promise<PlatformStatsPayload> {
|
||||
const manifestResponse = await fetch(manifestUrl, init);
|
||||
if (!manifestResponse.ok) throw new Error(`${manifestUrl} answered HTTP ${manifestResponse.status}`);
|
||||
const manifest = (await manifestResponse.json()) as Partial<PlatformStatsManifest> | null;
|
||||
if (typeof manifest?.latest !== "string" || !manifest.latest) {
|
||||
throw new Error(`${manifestUrl} names no versioned file`);
|
||||
}
|
||||
|
||||
// Relative to the manifest as served (after redirects); a hand-built Response has no url.
|
||||
const base = manifestResponse.url || manifestUrl;
|
||||
const payloadUrl = new URL(manifest.latest, base);
|
||||
if (payloadUrl.origin !== new URL(base).origin) {
|
||||
throw new Error(`${manifestUrl} names a file on another origin`);
|
||||
}
|
||||
|
||||
const payloadResponse = await fetch(payloadUrl.toString(), init);
|
||||
if (!payloadResponse.ok) throw new Error(`${payloadUrl} answered HTTP ${payloadResponse.status}`);
|
||||
const payload: unknown = await payloadResponse.json();
|
||||
if (typeof payload !== "object" || payload === null) throw new Error(`${payloadUrl} holds no stats object`);
|
||||
return payload as PlatformStatsPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's read. `no-cache` revalidates both files with the blob: the versioned file is
|
||||
* named per day but rewritten every 2 hours under `Cache-Control: immutable`, so a copy cached
|
||||
* earlier that day would replace the newer baked numbers with older ones.
|
||||
*/
|
||||
export async function fetchLivePlatformStats(
|
||||
manifestUrl: string = PLATFORM_STATS_MANIFEST_URL,
|
||||
): Promise<PlatformStatsPayload | null> {
|
||||
try {
|
||||
return await readPlatformStats(manifestUrl, { cache: "no-cache" });
|
||||
} catch {
|
||||
// The band is decorative: a failed read only means no live numbers.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The build's read (app/page.tsx), once per `next build`. Only for an absolute URL: local and CI
|
||||
* builds have none (the relative fallback exists only in a browser) and bake nothing.
|
||||
* `no-store` keeps the read out of Next's fetch cache, which would otherwise hand the next build
|
||||
* on the same machine the previous numbers; the page is `force-static`, so `no-store` stays
|
||||
* legal in the static export. Fail-soft: without a snapshot the page ships the skeleton and
|
||||
* browsers load the numbers live, as before; deploy-web.yml reports it as a warning.
|
||||
*/
|
||||
export async function loadPlatformStatsSnapshot(
|
||||
manifestUrl: string = PLATFORM_STATS_MANIFEST_URL,
|
||||
): Promise<PlatformStatsSnapshot | null> {
|
||||
if (!/^https?:\/\//i.test(manifestUrl)) return null;
|
||||
try {
|
||||
const payload = await readPlatformStats(manifestUrl, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(BUILD_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const items = toStatItems(payload);
|
||||
if (items.length === 0) throw new Error("the stats file holds no positive figure");
|
||||
const asOf = typeof payload.lastUpdated === "string" ? payload.lastUpdated : null;
|
||||
console.log(`Platform stats baked into the landing page (as of ${asOf ?? "unknown"}).`);
|
||||
return { items, asOf };
|
||||
} catch (err) {
|
||||
// Node's fetch reports every network failure as "fetch failed" and keeps the reason in `cause`.
|
||||
const cause = err instanceof Error && err.cause instanceof Error ? ` (${err.cause.message})` : "";
|
||||
const reason = err instanceof Error ? `${err.message}${cause}` : String(err);
|
||||
console.warn(`Platform stats not baked into the landing page; browsers still load them live. ${reason}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const OUTPUT_FILE = path.join(WEB_ROOT, "utils/page-lastmod.generated.ts");
|
||||
// fails the build for any URL missing here. Documentation and the changelog
|
||||
// live on docs.autopilotmonitor.com (GitBook) and are not part of this site.
|
||||
const PAGE_MAP = {
|
||||
"/": ["app/page.tsx"],
|
||||
"/": ["app/page.tsx", "components/landing/LandingPage.tsx"],
|
||||
"/about": ["app/about/page.tsx", "app/about/faq.ts"],
|
||||
"/ai": ["app/ai/page.tsx", "components/ai/questions.ts"],
|
||||
"/get-started": ["app/get-started/page.tsx"],
|
||||
|
||||
Reference in New Issue
Block a user