Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/gittensory-ui/src/lib/analytics-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import {
ANALYTICS_WINDOW_STORAGE_KEY,
DEFAULT_ANALYTICS_WINDOW_DAYS,
operatorDashboardPath,
parseAnalyticsWindowDays,
} from "@/lib/analytics-window";
import { useApiResource } from "@/lib/api/use-api-resource";
import { useLocalStorage } from "@/lib/use-local-storage";

const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
vi.mock("@/lib/api/request", () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" }));

describe("parseAnalyticsWindowDays (#2199)", () => {
it("defaults invalid values to 7d", () => {
expect(parseAnalyticsWindowDays(undefined)).toBe(7);
expect(parseAnalyticsWindowDays("14")).toBe(7);
});

it("accepts the supported 7/30/90 day windows", () => {
expect(parseAnalyticsWindowDays(30)).toBe(30);
expect(parseAnalyticsWindowDays("90")).toBe(90);
});
});

describe("operatorDashboardPath (#2199)", () => {
it("threads the selected window into the fetch path", () => {
expect(operatorDashboardPath(7)).toBe("/v1/app/operator-dashboard?days=7");
expect(operatorDashboardPath(30)).toBe("/v1/app/operator-dashboard?days=30");
});
});

describe("analytics window persistence (#2199)", () => {
it("restores a persisted value from localStorage", async () => {
window.localStorage.setItem(ANALYTICS_WINDOW_STORAGE_KEY, JSON.stringify(90));
const { result } = renderHook(() =>
useLocalStorage(ANALYTICS_WINDOW_STORAGE_KEY, DEFAULT_ANALYTICS_WINDOW_DAYS),
);
await waitFor(() => expect(result.current[2]).toBe(true));
expect(result.current[0]).toBe(90);
});

it("falls back to the default when storage is empty", async () => {
window.localStorage.removeItem(ANALYTICS_WINDOW_STORAGE_KEY);
const { result } = renderHook(() =>
useLocalStorage(ANALYTICS_WINDOW_STORAGE_KEY, DEFAULT_ANALYTICS_WINDOW_DAYS),
);
await waitFor(() => expect(result.current[2]).toBe(true));
expect(result.current[0]).toBe(DEFAULT_ANALYTICS_WINDOW_DAYS);
});
});

describe("useApiResource window re-key (#2199)", () => {
it("refetches when the dashboard path changes", async () => {
apiFetch.mockResolvedValue({ ok: true, data: { metrics: [] }, status: 200, durationMs: 5 });
const { result, rerender } = renderHook(
({ path }: { path: string }) => useApiResource<{ metrics: [] }>(path, "Product analytics"),
{ initialProps: { path: operatorDashboardPath(7) } },
);
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(apiFetch).toHaveBeenCalledWith(
"https://api.test/v1/app/operator-dashboard?days=7",
expect.any(Object),
);

rerender({ path: operatorDashboardPath(30) });
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
"https://api.test/v1/app/operator-dashboard?days=30",
expect.any(Object),
),
);
});
});
15 changes: 15 additions & 0 deletions apps/gittensory-ui/src/lib/analytics-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const ANALYTICS_WINDOW_OPTIONS = [7, 30, 90] as const;
export type AnalyticsWindowDays = (typeof ANALYTICS_WINDOW_OPTIONS)[number];
export const DEFAULT_ANALYTICS_WINDOW_DAYS: AnalyticsWindowDays = 7;
export const ANALYTICS_WINDOW_STORAGE_KEY = "gittensory.analytics.windowDays";

export function parseAnalyticsWindowDays(value: unknown): AnalyticsWindowDays {
const numeric = Number(value);
return ANALYTICS_WINDOW_OPTIONS.includes(numeric as AnalyticsWindowDays)
? (numeric as AnalyticsWindowDays)
: DEFAULT_ANALYTICS_WINDOW_DAYS;
}

export function operatorDashboardPath(windowDays: AnalyticsWindowDays): string {
return `/v1/app/operator-dashboard?days=${windowDays}`;
}
42 changes: 40 additions & 2 deletions apps/gittensory-ui/src/routes/app.analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { BoundaryBadge, Stat, StatusPill } from "@/components/site/control-primitives";
import { RefreshMeta } from "@/components/site/refresh-meta";
import { StateActionButton, StateBoundary } from "@/components/site/state-views";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { TrendChart } from "@/components/site/trend-chart";
import {
AdoptionRetentionPanel,
Expand All @@ -26,8 +27,17 @@
FindingsBreakdownCard,
type FindingsBreakdown,
} from "@/components/site/app-panels/findings-breakdown-card";
import {
ANALYTICS_WINDOW_OPTIONS,
ANALYTICS_WINDOW_STORAGE_KEY,
DEFAULT_ANALYTICS_WINDOW_DAYS,
operatorDashboardPath,
parseAnalyticsWindowDays,
type AnalyticsWindowDays,
} from "@/lib/analytics-window";
import { useApiResource } from "@/lib/api/use-api-resource";
import { exportOperatorDashboardCsv } from "@/lib/csv-export";
import { useLocalStorage } from "@/lib/use-local-storage";

export const Route = createFileRoute("/app/analytics")({
component: ProductAnalytics,
Expand Down Expand Up @@ -123,9 +133,14 @@
findingsBreakdown?: FindingsBreakdown;
};

function ProductAnalytics() {

Check warning on line 136 in apps/gittensory-ui/src/routes/app.analytics.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option
const [windowDays, setWindowDays, windowHydrated] = useLocalStorage<AnalyticsWindowDays>(
ANALYTICS_WINDOW_STORAGE_KEY,
DEFAULT_ANALYTICS_WINDOW_DAYS,
);
const selectedWindow = parseAnalyticsWindowDays(windowDays);
const dashboard = useApiResource<OperatorDashboard>(
"/v1/app/operator-dashboard",
operatorDashboardPath(selectedWindow),
"Product analytics",
);
const data = dashboard.status === "ready" ? dashboard.data : null;
Expand Down Expand Up @@ -161,7 +176,30 @@
usage rollups — not security audit logs or private source data.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{windowHydrated ? (
<ToggleGroup
type="single"
size="sm"
variant="outline"
value={String(selectedWindow)}
onValueChange={(value) => {
if (!value) return;
setWindowDays(parseAnalyticsWindowDays(Number(value)));
}}
aria-label="Analytics time window"
>
{ANALYTICS_WINDOW_OPTIONS.map((days) => (
<ToggleGroupItem
key={days}
value={String(days)}
aria-label={`${days} day window`}
>
{days}d
</ToggleGroupItem>
))}
</ToggleGroup>
) : null}
<StatusPill
status={
data.usageRollupStatus?.status === "ready" ||
Expand Down
5 changes: 3 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ import {
LATEST_RECOMMENDED_MCP_VERSION,
MINIMUM_SUPPORTED_MCP_VERSION,
} from "../services/mcp-compatibility";
import { buildOperatorDashboardPayload } from "../services/operator-dashboard";
import { buildOperatorDashboardPayload, clampOperatorDashboardWindowDays } from "../services/operator-dashboard";
import { buildSelfDogfoodRegistrationPack, resolveSelfDogfoodRepoFullName } from "../services/self-dogfood-registration-pack";
import { buildSubnetInterfaceDescriptor } from "../services/subnet-interface";
import { buildPublicRepoQuality, type PublicRepoQuality } from "../services/public-repo-quality";
Expand Down Expand Up @@ -1457,7 +1457,8 @@ export function createApp() {
app.get("/v1/app/operator-dashboard", async (c) => {
const forbidden = await requireAppRole(c, ["operator"]);
if (forbidden) return forbidden;
return c.json(await buildOperatorDashboardPayload(c.env));
const days = clampOperatorDashboardWindowDays(Number(c.req.query("days")));
return c.json(await buildOperatorDashboardPayload(c.env, { windowDays: days }));
});

// Dead-letter-queue table view (#2214), read-only: the self-host queue backend's admin surface is mirrored
Expand Down
35 changes: 24 additions & 11 deletions src/services/operator-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,16 @@ export type OperatorDashboardPayload = {
};

const USAGE_WINDOW_DAYS = 7;
// Gate-precision and cycle-time cards (#2191/#2194) keep a fixed 90d lookback for statistical stability.
const GATE_ANALYTICS_WINDOW_DAYS = 90;

export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorDashboardPayload> {
const usageSince = new Date(Date.now() - USAGE_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
export async function buildOperatorDashboardPayload(
env: Env,
options: { windowDays?: number } = {},
): Promise<OperatorDashboardPayload> {
const windowDays = clampOperatorDashboardWindowDays(options.windowDays);
const usageSince = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000).toISOString();
const mcpSince = new Date(Date.now() - USAGE_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
const [
repositories,
installations,
Expand Down Expand Up @@ -108,20 +115,20 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
summarizeProductUsageEvents(env, usageSince),
listProductUsageDailyRollups(env, { limit: 14 }),
getProductUsageRollupStatus(env),
summarizeMcpCompatibilityAdoption(env, usageSince),
getCommandUsefulnessSummary(env),
buildRecommendationQualityReport(env, { windowDays: 90 }),
computeFleetAnalytics(env, { windowDays: 90 }),
summarizeMcpCompatibilityAdoption(env, mcpSince),
getCommandUsefulnessSummary(env, { windowDays }),
buildRecommendationQualityReport(env, { windowDays: GATE_ANALYTICS_WINDOW_DAYS }),
computeFleetAnalytics(env, { windowDays: GATE_ANALYTICS_WINDOW_DAYS }),
// #2191: reuse the existing eval (no new compute); it fails safe to an empty report on any read error.
computeGateEval(env, { days: 90, nowMs: Date.now() }),
computeGateEval(env, { days: GATE_ANALYTICS_WINDOW_DAYS, nowMs: Date.now() }),
// #2194: cycle-time percentiles from the stats feed; fails safe to an empty aggregate.
computeCycleTimeAggregate(env, { days: 90, nowMs: Date.now() }),
computeCycleTimeAggregate(env, { days: GATE_ANALYTICS_WINDOW_DAYS, nowMs: Date.now() }),
computeAgentHealth(env, operatorAgentConfig(env)),
]);
const weeklyValueReport = buildWeeklyValueReport({
generatedAt: nowIso(),
variant: "operator",
days: USAGE_WINDOW_DAYS,
days: windowDays,
repositories,
installations,
health,
Expand All @@ -143,8 +150,8 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
{ label: "Installations", value: String(installations.length), delta: `${installedRepos} installed repos` },
{ label: "Registered repos", value: String(registeredRepos), delta: registry ? `${registry.repoCount} in latest registry` : "registry missing" },
{ label: "Digest subscriptions", value: String(digestSubscriptions), delta: "store-only" },
{ label: "Product events", value: String(usageSummary.totalEvents), delta: "last 7 days" },
{ label: "Active users", value: String(usageSummary.activeActors), delta: "hashed, last 7 days" },
{ label: "Product events", value: String(usageSummary.totalEvents), delta: `last ${windowDays} days` },
{ label: "Active users", value: String(usageSummary.activeActors), delta: `hashed, last ${windowDays} days` },
{ label: "Activation rollups", value: usageRollupStatus.status, delta: usageRollupStatus.latestRollupDay ?? "not generated" },
{
label: "MCP stale clients",
Expand Down Expand Up @@ -240,3 +247,9 @@ function sparklineFromCounts(value: number, total: number): number[] {
const ratio = Math.min(1, Math.max(0, value / safeTotal));
return [Math.round(ratio * 40), Math.round(ratio * 55), Math.round(ratio * 70), Math.round(ratio * 85), Math.round(ratio * 100)];
}

export function clampOperatorDashboardWindowDays(value: number | undefined): number {
const numeric = Number(value);
if (numeric === 7 || numeric === 30 || numeric === 90) return numeric;
return USAGE_WINDOW_DAYS;
}
37 changes: 36 additions & 1 deletion test/unit/operator-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildOperatorDashboardPayload, latestUsageRollup, __operatorDashboardInternals } from "../../src/services/operator-dashboard";
import {
buildOperatorDashboardPayload,
clampOperatorDashboardWindowDays,
latestUsageRollup,
__operatorDashboardInternals,
} from "../../src/services/operator-dashboard";
import type { ProductUsageDailyRollupRecord } from "../../src/types";
import { createTestEnv } from "../helpers/d1";

Expand Down Expand Up @@ -121,6 +126,36 @@ describe("operator dashboard payload", () => {
);
});

it("clamps unsupported window values to the default 7d lookback (#2199)", () => {
expect(clampOperatorDashboardWindowDays(30)).toBe(30);
expect(clampOperatorDashboardWindowDays(14)).toBe(7);
});

it("threads a custom windowDays through command usefulness metadata (#2199)", async () => {
const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "operator-dashboard-test-salt" });
const payload = await buildOperatorDashboardPayload(env, { windowDays: 90 });
expect(payload.commandUsefulness.windowDays).toBe(90);
expect(payload.metrics).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "Product events", delta: "last 90 days" }),
]),
);
});

it("keeps gate-precision and cycle-time cards on a fixed 90d lookback when windowDays is 7 (#2199)", async () => {
const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "operator-dashboard-test-salt" });
const payload = await buildOperatorDashboardPayload(env, { windowDays: 7 });
expect(payload.commandUsefulness.windowDays).toBe(7);
expect(payload.gateEval).toEqual({ rows: [], hasSignal: false });
expect(payload.cycleTime).toEqual({
p50Ms: null,
p90Ms: null,
p99Ms: null,
distribution: [],
sampleSize: 0,
});
});

it("picks the newest rollup day for adoption insights", () => {
const rollups: ProductUsageDailyRollupRecord[] = [
rollup("2026-05-28"),
Expand Down
Loading