diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts b/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts
index 55116e21e..607009a5f 100644
--- a/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts
@@ -2,7 +2,7 @@ export const METRICS_NAV_SECTIONS = [
{ id: "customers", label: "Customers", description: "New tenants per day and the growth over time" },
{ id: "platform-metrics", label: "Platform Metrics", description: "Agent performance, delivery latency, crash rates, and platform health" },
{ id: "usage", label: "Platform Usage", description: "Platform usage statistics across all tenants" },
- { id: "mcp-usage", label: "MCP Usage", description: "MCP API usage metrics across all users" },
+ { id: "mcp-usage", label: "MCP Usage", description: "MCP usage by tenant, account and tool, and where the limits bite" },
{ id: "verdict-calibration", label: "Verdict Calibration", description: "Which code path produced each session verdict, overrides and re-enrollment — classifier diagnostics" },
{ id: "ime-pattern-health", label: "IME Pattern Health", description: "Which IME log patterns still match on which IME version — pattern-drift alerts and tracker health" },
] as const;
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx
index cbda88306..3b3692b5f 100644
--- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx
@@ -1,275 +1,282 @@
"use client";
-import { SegmentedControl } from "@/components/SegmentedControl";
-import { WINDOW_PRESET_OPTIONS } from "@/lib/timeWindow";
-import { useCallback, useEffect, useState } from "react";
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import dynamic from "next/dynamic";
+import { useSearchParams } from "next/navigation";
import { useAuth } from "../../../../contexts/AuthContext";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import { api } from "@/lib/api";
-import { isApplicationKey, principalLabel } from "@/lib/principalKeys";
-import type { GetGlobalMcpUsageDailyResponse, GetGlobalMcpUsageResponse, UserUsageDailySummary } from "@/lib/generated/wire-types.generated";
+import { SegmentedControl } from "@/components/SegmentedControl";
+import { CalculatingDialog } from "@/components/CalculatingCard";
+import { chartColors } from "@/components/charts/chartTheme";
+import { useFetchProgress } from "@/hooks/useFetchProgress";
+import { useWindowDays } from "@/hooks/useWindowDays";
+import { WINDOW_PRESET_OPTIONS } from "@/lib/timeWindow";
+import type {
+ GetGlobalMcpUsageOverviewResponse,
+ PlanTierDefinitionsResponse,
+ TenantConfiguration,
+} from "@/lib/generated/wire-types.generated";
+import { Stat } from "./Stat";
+import {
+ dailyChart,
+ formatDayKey,
+ MCP_USAGE_MAX_DAYS,
+ MCP_USAGE_VIEW_PARAM,
+ overviewTotals,
+ parseView,
+ planLegend,
+ tenantInfoMap,
+ type McpUsageView,
+ type TenantInfo,
+} from "./mcpUsage/mcpUsageLogic";
+import { TenantsTab } from "./mcpUsage/TenantsTab";
+import { ToolsTab } from "./mcpUsage/ToolsTab";
+import { LimitsTab } from "./mcpUsage/LimitsTab";
-interface UsageRecord {
- userId: string;
- userPrincipalName: string;
- tenantId: string;
- endpoint: string;
- date: string;
- requestCount: number;
- lastRequestAt: string;
+function ChartPlaceholder() {
+ return
Loading chart…
;
}
-/** One per-day usage row (wire shape). */
-type DailySummary = UserUsageDailySummary;
+// recharts (~300 kB) loads only here: SectionClient imports every metrics section statically.
+const AppStackedBarChart = dynamic(() => import("@/components/charts/AppStackedBarChart"), { ssr: false, loading: ChartPlaceholder });
-function formatDate(yyyymmdd: string): string {
- if (yyyymmdd.length !== 8) return yyyymmdd;
- return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
-}
-
-function getDateFrom(days: number): string {
- const d = new Date();
- d.setDate(d.getDate() - days);
- return d.toISOString().slice(0, 10).replace(/-/g, "");
-}
-
-function getDateTo(): string {
- return new Date().toISOString().slice(0, 10).replace(/-/g, "");
-}
-
-interface UserAggregate {
- upn: string;
- userId: string;
- tenantId: string;
- totalRequests: number;
- lastActive: string;
-}
+const DEFAULT_WINDOW_DAYS = 30;
+const RANGE_DAY: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" };
+const PLAN_COLORS: Record = { community: chartColors.success, pro: chartColors.primary, msp: chartColors.warning };
+const NO_TENANT_INFO: ReadonlyMap = new Map();
+/**
+ * GA metrics: MCP usage over every tenant (GET global/metrics/mcp-usage/overview). Who uses MCP (tenants,
+ * accounts, service principals), which tools and at what cost per call, and where the quota and the two
+ * per-minute limits bite. The window lives in ?days= (at most 90, the telemetry's reach), the tab in ?view=.
+ */
export function SectionMcpUsage() {
+ // useSearchParams (?days=, ?view=) requires a Suspense boundary under the static export.
+ return (
+ }>
+
+
+ );
+}
+
+function McpUsageContent() {
const { getAccessToken } = useAuth();
- const [records, setRecords] = useState([]);
- const [dailySummaries, setDailySummaries] = useState([]);
+ const searchParams = useSearchParams();
+ // Platform roles may ask for more; the telemetry keeps 90 days, so the page shows at most that.
+ const { days: requestedDays, setDays } = useWindowDays({ defaultDays: DEFAULT_WINDOW_DAYS, tenantId: undefined });
+ const days = Math.min(requestedDays, MCP_USAGE_MAX_DAYS);
+ const view = parseView(searchParams?.get(MCP_USAGE_VIEW_PARAM));
+ const [includePlatformAdmins, setIncludePlatformAdmins] = useState(false);
+ const [overview, setOverview] = useState(null);
+ const [tenantRows, setTenantRows] = useState<{ tenants: TenantConfiguration[]; nowMs: number } | null>(null);
+ const [planTiers, setPlanTiers] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [days, setDays] = useState(30);
+ const { begin, finish, elapsedMs, estimateMs } = useFetchProgress("mcpUsageOverview.estimateMs", 10_000);
+ // A slower answer for an older window must never replace a newer one.
+ const requestRef = useRef(0);
- const fetchData = useCallback(async (windowDays: number) => {
+ const setView = useCallback((next: McpUsageView) => {
+ const url = new URL(window.location.href);
+ if (next === "tenants") url.searchParams.delete(MCP_USAGE_VIEW_PARAM);
+ else url.searchParams.set(MCP_USAGE_VIEW_PARAM, next);
+ window.history.replaceState(null, "", url.toString());
+ }, []);
+
+ const fetchOverview = useCallback(async () => {
+ const request = ++requestRef.current;
setLoading(true);
setError(null);
+ begin();
+ let fresh = false;
try {
- const dateFrom = getDateFrom(windowDays);
- const dateTo = getDateTo();
-
- const [globalData, dailyData] = await Promise.all([
- fetchJson(api.mcpUsage.global(undefined, dateFrom, dateTo), getAccessToken),
- fetchJson(api.mcpUsage.daily(undefined, dateFrom, dateTo), getAccessToken),
- ]);
-
- setRecords(globalData.records || []);
- setDailySummaries(dailyData.summaries || []);
+ const data = await fetchJson(
+ api.mcpUsage.overview(days, includePlatformAdmins ? "include" : "exclude"),
+ getAccessToken,
+ );
+ if (request === requestRef.current) {
+ setOverview(data);
+ fresh = true;
+ }
} catch (err) {
- setError(apiErrorText(err, "Failed to fetch usage data"));
+ if (request === requestRef.current) setError(apiErrorText(err, "Failed to load MCP usage"));
} finally {
- setLoading(false);
+ if (request === requestRef.current) {
+ setLoading(false);
+ finish(fresh);
+ }
}
+ }, [days, includePlatformAdmins, getAccessToken, begin, finish]);
+
+ // Names, editions and the plan legend: once per visit. Without them the page shows tenant ids and no legend.
+ const fetchReferenceData = useCallback(async () => {
+ const [tenants, tiers] = await Promise.allSettled([
+ fetchJson(api.config.all(), getAccessToken),
+ fetchJson(api.mcpUsage.planTiers(), getAccessToken),
+ ]);
+ if (tenants.status === "fulfilled") setTenantRows({ tenants: tenants.value, nowMs: Date.now() });
+ if (tiers.status === "fulfilled") setPlanTiers(tiers.value);
}, [getAccessToken]);
useEffect(() => {
const run = async () => {
- await fetchData(days);
+ await fetchOverview();
};
void run();
- }, [fetchData, days]);
+ }, [fetchOverview]);
- // Aggregate by user
- const userAggregates: UserAggregate[] = (() => {
- const byUser = new Map();
- for (const r of records) {
- const key = r.userId || r.userPrincipalName;
- const existing = byUser.get(key);
- if (existing) {
- existing.totalRequests += r.requestCount;
- if (r.lastRequestAt > existing.lastActive) existing.lastActive = r.lastRequestAt;
- } else {
- byUser.set(key, {
- upn: r.userPrincipalName,
- userId: r.userId,
- tenantId: r.tenantId,
- totalRequests: r.requestCount,
- lastActive: r.lastRequestAt,
- });
- }
- }
- return Array.from(byUser.values()).sort((a, b) => b.totalRequests - a.totalRequests);
- })();
+ useEffect(() => {
+ const run = async () => {
+ await fetchReferenceData();
+ };
+ void run();
+ }, [fetchReferenceData]);
- // Sorted daily summaries (newest first)
- const sortedDaily = [...dailySummaries].sort((a, b) => b.date.localeCompare(a.date));
+ const info = useMemo(
+ () => (tenantRows ? tenantInfoMap(tenantRows.tenants, tenantRows.nowMs) : NO_TENANT_INFO),
+ [tenantRows],
+ );
+ const totals = useMemo(() => (overview ? overviewTotals(overview, info) : null), [overview, info]);
+ const chart = useMemo(() => (overview ? dailyChart(overview) : null), [overview]);
+ const plans = useMemo(() => planLegend(planTiers), [planTiers]);
- const totalRequests = sortedDaily.reduce((sum, d) => sum + d.totalRequests, 0);
- const uniqueUsersTotal = new Set(records.map(r => r.userId || r.userPrincipalName)).size;
- const todayStr = getDateTo();
- const todayRequests = sortedDaily.find(d => d.date === todayStr)?.totalRequests ?? 0;
- const maxDaily = Math.max(...sortedDaily.map(d => d.totalRequests), 1);
+ const viewOptions: { value: McpUsageView; label: string }[] = [
+ { value: "tenants", label: "Tenants & accounts" },
+ { value: "tools", label: "Tools" },
+ { value: "limits", label: totals && totals.limitAccounts > 0 ? `Limits (${totals.limitAccounts})` : "Limits" },
+ ];
+
+ const telemetryDark = overview && !overview.telemetry.requestsAvailable && !overview.telemetry.mcpLogAvailable;
return (
- // Self-provides the max-w container the metrics layout used to supply (the other sections
- // self-wrap with their own min-h-screen layout; this one is a plain content block).
+ // Self-provides the max-w container the metrics layout used to supply.
- {/* Header */}
-
Global MCP Usage
-
API usage across all MCP-enabled users
+
MCP Usage
+
+ {overview
+ ? `${formatDayKey(overview.dateFrom, RANGE_DAY)} – ${formatDayKey(overview.dateTo, RANGE_DAY)} · ${overview.includesPlatformAdmins ? "platform admins included" : "customers only"}`
+ : "MCP usage across all tenants"}
+
-
+
+ setIncludePlatformAdmins(e.target.checked)}
+ className="rounded border-gray-300"
+ />
+ Include platform admins
+
+
fetchData(days)}
+ onClick={() => void fetchOverview()}
disabled={loading}
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
- {loading ? "Loading..." : "Refresh"}
+ Refresh
{error && (
-
- {error}
-
+
{error}
)}
- {/* Summary Cards */}
-
-
-
Today
-
{todayRequests.toLocaleString()}
+ {loading ? (
+
+
-
-
Total ({days}d)
-
{totalRequests.toLocaleString()}
-
-
-
Unique Users
-
{uniqueUsersTotal}
-
-
-
Active Days
-
{sortedDaily.length}
-
-
+ ) : overview && totals && chart ? (
+ <>
+
+ 0 ? `${totals.toolCount} tools · ${totals.mcpOnlyCalls.toLocaleString()} MCP-only` : `${totals.toolCount} tools`}
+ />
+
+
+
+ setView("limits")}
+ title="Show the limits"
+ className="text-left rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ >
+
+
+
- {/* Daily Usage Chart */}
- {sortedDaily.length > 0 && (
-
-
Daily Request Volume
-
- {sortedDaily.slice(0, 30).map((day) => (
-
-
- {formatDate(day.date)}
-
-
-
- {day.totalRequests.toLocaleString()}
- / {day.uniqueUsers} users
-
+
+
Calls per day
+ {!overview.telemetry.requestsAvailable ? (
+
The request telemetry did not answer: the calls per day are missing.
+ ) : chart.plans.length === 0 ? (
+
No tool calls in this window.
+ ) : (
+
+
({ dataKey: `plan:${plan}`, label: plan, color: PLAN_COLORS[plan] ?? chartColors.muted }))}
+ height={220}
+ formatXTick={(value) => formatDayKey(String(value))}
+ renderTooltip={(row) => }
+ />
- ))}
+ )}
-
- )}
- {/* Top Endpoints (aggregated across all users) */}
- {records.length > 0 && (() => {
- const endpointTotals = new Map
();
- for (const r of records) {
- endpointTotals.set(r.endpoint, (endpointTotals.get(r.endpoint) || 0) + r.requestCount);
- }
- const topEndpoints = Array.from(endpointTotals.entries())
- .sort((a, b) => b[1] - a[1])
- .slice(0, 15);
- return topEndpoints.length > 0 ? (
-
-
-
Top Endpoints
-
-
- {topEndpoints.map(([endpoint, count]) => {
- // MCP tool calls are recorded as "toolname:/api/route" — show the
- // tool name prominently and the route as a secondary line.
- const sep = endpoint.indexOf(":");
- const tool = sep > 0 ? endpoint.slice(0, sep) : endpoint;
- const route = sep > 0 ? endpoint.slice(sep + 1) : null;
- return (
-
-
-
{tool}
- {route && (
-
{route}
- )}
-
-
- {count.toLocaleString()}
- requests
-
-
- );
- })}
-
-
- ) : null;
- })()}
+
- {/* Top Users */}
- {userAggregates.length > 0 && (
-
-
-
Top Users by Request Count
-
-
- {userAggregates.slice(0, 25).map((user) => (
-
-
-
- {user.upn ? principalLabel(user.upn) : user.userId}
- {isApplicationKey(user.upn) && (
-
- App
-
- )}
-
-
- {user.tenantId ? user.tenantId.slice(0, 8) : "—"}
- {" · "}
- {user.lastActive ? new Date(user.lastActive).toLocaleDateString() : "—"}
-
-
-
- {user.totalRequests.toLocaleString()}
-
-
- ))}
-
-
- )}
-
- {/* Empty State */}
- {!loading && records.length === 0 && !error && (
-
-
No MCP usage data found for the selected period.
-
- )}
+ {view === "tenants" && }
+ {view === "tools" && }
+ {view === "limits" && (
+
+ )}
+ >
+ ) : null}
+
+ );
+}
+
+function DayTooltip({ row, plans }: { row: Record
; plans: readonly string[] }) {
+ const line = (label: string, value: unknown, indent = false) => (
+
+ {label}
+ {Number(value ?? 0).toLocaleString()}
+
+ );
+ return (
+
+
{formatDayKey(String(row.date), { weekday: "short", day: "numeric", month: "short" })}
+ {line("Tool calls", row.calls)}
+ {plans.map((plan) => line(plan, row[`plan:${plan}`], true))}
+ {line("Requests", row.requests)}
+ {line("Accounts", row.accounts)}
);
}
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/__tests__/mcpUsageLogic.test.ts b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/__tests__/mcpUsageLogic.test.ts
new file mode 100644
index 000000000..0d01c4376
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/__tests__/mcpUsageLogic.test.ts
@@ -0,0 +1,279 @@
+import { describe, expect, it } from "vitest";
+import type {
+ GetGlobalMcpUsageOverviewResponse,
+ McpUsageAccountItem,
+ McpUsageMinuteItem,
+ McpUsageTenantItem,
+ McpUsageToolItem,
+ PlanTierDefinitionsResponse,
+ TenantConfiguration,
+} from "@/lib/generated/wire-types.generated";
+import {
+ accountNearLimit,
+ dailyChart,
+ formatChars,
+ formatDayKey,
+ formatDuration,
+ formatShare,
+ minuteRowsToShow,
+ minuteShare,
+ overviewTotals,
+ parseView,
+ planLegend,
+ searchTenants,
+ sortTenants,
+ sortTools,
+ tenantInfoMap,
+ tenantLabel,
+ tenantNearLimit,
+ usagePercent,
+} from "../mcpUsage/mcpUsageLogic";
+
+const TENANT_A = "aaaaaaaa-0000-0000-0000-00000000000a";
+const TENANT_B = "bbbbbbbb-0000-0000-0000-00000000000b";
+
+function account(principal: string, over: Partial = {}): McpUsageAccountItem {
+ return {
+ principal,
+ isPlatformAdmin: false,
+ quota: { plan: "community", dailyLimit: 50, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 20 },
+ calls: 5,
+ requests: 10,
+ quotaHits: 0,
+ topTools: [],
+ ...over,
+ };
+}
+
+function tenant(tenantId: string, accounts: McpUsageAccountItem[], over: Partial = {}): McpUsageTenantItem {
+ return {
+ tenantId,
+ quota: { tenantPlan: "community", dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 20 },
+ calls: accounts.reduce((s, a) => s + a.calls, 0),
+ requests: accounts.reduce((s, a) => s + a.requests, 0),
+ quotaHits: 0,
+ accounts,
+ ...over,
+ };
+}
+
+function config(tenantId: string, domainName: string, planTier: string, over: Partial = {}): TenantConfiguration {
+ return { tenantId, domainName, planTier, trialConsumed: false, payingCustomer: false, disabled: false, ...over } as TenantConfiguration;
+}
+
+const INFO = tenantInfoMap(
+ [config(TENANT_A.toUpperCase(), "contoso.com", "free", { companyName: "Contoso Ltd" }), config(TENANT_B, "fabrikam.com", "pro")],
+ Date.UTC(2026, 10, 5),
+);
+
+describe("parseView", () => {
+ it("keeps the two other tabs and falls back to tenants", () => {
+ expect(parseView("tools")).toBe("tools");
+ expect(parseView("limits")).toBe("limits");
+ expect(parseView(null)).toBe("tenants");
+ expect(parseView("Tools")).toBe("tenants");
+ });
+});
+
+describe("tenantInfoMap", () => {
+ it("keys by the lower-cased id and resolves the edition", () => {
+ expect(INFO.get(TENANT_A)?.domainName).toBe("contoso.com");
+ expect(INFO.get(TENANT_A)?.edition).toBe("community");
+ expect(INFO.get(TENANT_B)?.edition).toBe("pro");
+ expect(tenantLabel(TENANT_B, INFO)).toBe("fabrikam.com");
+ expect(tenantLabel("cccccccc-0000-0000-0000-00000000000c", INFO)).toBe("cccccccc…");
+ });
+});
+
+describe("usagePercent and near-limit", () => {
+ it("reads unlimited and unavailable windows as no share", () => {
+ expect(usagePercent(40, 50)).toBe(80);
+ expect(usagePercent(71, 50)).toBe(142);
+ expect(usagePercent(5, 0)).toBeNull();
+ expect(usagePercent(-1, 50)).toBeNull();
+ });
+
+ it("flags an account at 80 % of a window or with a refusal", () => {
+ expect(accountNearLimit(account("a@contoso.com", { quota: { plan: "community", dailyLimit: 50, monthlyLimit: 250, dailyUsed: 40, monthlyUsed: 40 } }))).toBe(true);
+ expect(accountNearLimit(account("a@contoso.com", { quotaHits: 1 }))).toBe(true);
+ expect(accountNearLimit(account("a@contoso.com"))).toBe(false);
+ expect(accountNearLimit(account("a@contoso.com", { quota: undefined }))).toBe(false);
+ });
+
+ it("flags a tenant through its organization window or any account", () => {
+ expect(tenantNearLimit(tenant(TENANT_A, [account("a@contoso.com")]))).toBe(false);
+ expect(tenantNearLimit(tenant(TENANT_A, [account("a@contoso.com")], { quota: { tenantPlan: "community", dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 200 } }))).toBe(true);
+ expect(tenantNearLimit(tenant(TENANT_A, [account("a@contoso.com", { quotaHits: 2 })]))).toBe(true);
+ });
+});
+
+describe("sortTenants", () => {
+ it("puts an unlimited window below every real share", () => {
+ const limited = tenant(TENANT_A, [account("a@contoso.com")]);
+ const unlimited = tenant(TENANT_B, [account("b@fabrikam.com")], { quota: { tenantPlan: "pro", dailyLimit: 0, monthlyLimit: 0, dailyUsed: 900, monthlyUsed: 900 } });
+ expect(sortTenants([unlimited, limited], "today", "desc").map((t) => t.tenantId)).toEqual([TENANT_A, TENANT_B]);
+ expect(sortTenants([limited, unlimited], "requests", "asc").map((t) => t.tenantId)).toEqual([TENANT_A, TENANT_B]);
+ });
+});
+
+describe("searchTenants", () => {
+ const alice = account("alice@contoso.com");
+ const app = account("app:11111111-2222-3333-4444-555555555555");
+ const bob = account("bob@fabrikam.com", { quotaHits: 1 });
+ const tenants = [tenant(TENANT_A, [alice, app]), tenant(TENANT_B, [bob])];
+
+ it("keeps every tenant and account without a query", () => {
+ expect(searchTenants(tenants, INFO, " ", false).map((m) => m.accounts.length)).toEqual([2, 1]);
+ });
+
+ it("keeps all accounts of a tenant matched by domain, company or id", () => {
+ for (const q of ["CONTOSO.COM", "contoso ltd", "aaaaaaaa"]) {
+ const [match] = searchTenants(tenants, INFO, q, false);
+ expect(match.tenant.tenantId).toBe(TENANT_A);
+ expect(match.accounts).toHaveLength(2);
+ expect(match.matchedByAccount).toBe(false);
+ }
+ });
+
+ it("keeps only the matching accounts of a tenant matched by an account, and opens it", () => {
+ const [match] = searchTenants(tenants, INFO, "11111111-2222", false);
+ expect(match.accounts.map((a) => a.principal)).toEqual([app.principal]);
+ expect(match.matchedByAccount).toBe(true);
+ });
+
+ it("near a limit only keeps tenants and accounts at a limit", () => {
+ const matches = searchTenants(tenants, INFO, "", true);
+ expect(matches.map((m) => m.tenant.tenantId)).toEqual([TENANT_B]);
+ expect(matches[0].accounts).toEqual([bob]);
+ });
+});
+
+function overview(over: Partial = {}): GetGlobalMcpUsageOverviewResponse {
+ return {
+ dateFrom: "20261103",
+ dateTo: "20261105",
+ includesPlatformAdmins: false,
+ telemetry: { requestsAvailable: true, mcpLogAvailable: true },
+ daily: [],
+ tenants: [],
+ tools: [],
+ quotaHits: [],
+ minuteLimits: { accounts: [] },
+ ...over,
+ };
+}
+
+function tool(name: string, calls: number, over: Partial = {}): McpUsageToolItem {
+ return { tool: name, calls, accounts: 1, tenants: 1, requestsPerCall: 1, routes: [], ...over };
+}
+
+function minute(principal: string, over: Partial = {}): McpUsageMinuteItem {
+ return { tenantId: TENANT_A, principal, peakCalls: 0, mcpRefused: 0, peakRequests: 0, backendLimit: 120, backendRefused: 0, ...over };
+}
+
+describe("overviewTotals", () => {
+ it("counts MCP-only calls in the total and every limited account once", () => {
+ const totals = overviewTotals(
+ overview({
+ tenants: [tenant(TENANT_A, [account("alice@contoso.com"), account("app:11111111-2222-3333-4444-555555555555")]), tenant(TENANT_B, [account("bob@fabrikam.com")])],
+ tools: [tool("get_session_summary", 10), tool("search_docs", 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0 })],
+ quotaHits: [
+ { date: "20261105", tenantId: TENANT_A, principal: "alice@contoso.com", level: "user", scope: "daily", plan: "community", refused: 3, firstAt: "", lastAt: "" },
+ { date: "20261104", tenantId: TENANT_A, principal: "alice@contoso.com", level: "user", scope: "daily", plan: "community", refused: 1, firstAt: "", lastAt: "" },
+ ],
+ minuteLimits: { accounts: [minute("alice@contoso.com", { backendRefused: 2 }), minute("bob@fabrikam.com", { tenantId: TENANT_B, mcpRefused: 7 })] },
+ }),
+ INFO,
+ );
+
+ expect(totals.calls).toBe(14);
+ expect(totals.mcpOnlyCalls).toBe(4);
+ expect(totals.toolCount).toBe(2);
+ expect(totals.requests).toBe(30);
+ expect([totals.tenants, totals.communityTenants, totals.proTenants]).toEqual([2, 1, 1]);
+ expect([totals.accounts, totals.apps]).toEqual([3, 1]);
+ expect([totals.limitAccounts, totals.quotaHitAccounts, totals.minuteRefusedAccounts]).toEqual([2, 1, 2]);
+ });
+});
+
+describe("dailyChart", () => {
+ it("stacks the plans by their total calls and fills days without calls with 0", () => {
+ const chart = dailyChart(
+ overview({
+ daily: [
+ { date: "20261104", calls: 3, requests: 9, accounts: 1, callsByPlan: [{ plan: "pro", calls: 3 }] },
+ { date: "20261105", calls: 12, requests: 20, accounts: 2, callsByPlan: [{ plan: "community", calls: 10 }, { plan: "pro", calls: 2 }] },
+ ],
+ }),
+ );
+
+ expect(chart.plans).toEqual(["community", "pro"]);
+ expect(chart.rows[0]).toEqual({ date: "20261104", calls: 3, requests: 9, accounts: 1, "plan:community": 0, "plan:pro": 3 });
+ });
+});
+
+describe("per-minute rows", () => {
+ it("takes the higher share of the two limits", () => {
+ expect(minuteShare(minute("a", { peakCalls: 30, peakRequests: 108 }), 60)).toBe(90);
+ expect(minuteShare(minute("a", { peakCalls: 45, peakRequests: 12 }), 60)).toBe(75);
+ // No MCP limit reported yet: only the backend share counts.
+ expect(minuteShare(minute("a", { peakCalls: 59, peakRequests: 12 }), undefined)).toBe(10);
+ });
+
+ it("shows a refusal at any peak, else a peak at half a limit", () => {
+ const rows = [
+ minute("low", { peakCalls: 5, peakRequests: 10 }),
+ minute("refused", { peakRequests: 10, backendRefused: 1 }),
+ minute("close", { peakCalls: 30 }),
+ ];
+ expect(minuteRowsToShow(rows, 60).map((r) => r.principal)).toEqual(["refused", "close"]);
+ });
+});
+
+describe("sortTools", () => {
+ it("puts a figure the MCP log did not deliver below every real value", () => {
+ const tools = [tool("a", 5, { errorRate: undefined }), tool("b", 1, { errorRate: 0.2 }), tool("c", 9, { errorRate: 0 })];
+ expect(sortTools(tools, "errorRate", "desc").map((t) => t.tool)).toEqual(["b", "c", "a"]);
+ expect(sortTools(tools, "calls", "desc").map((t) => t.tool)).toEqual(["c", "a", "b"]);
+ });
+});
+
+describe("planLegend", () => {
+ it("fills a blank organization window of community or pro from the catalog only", () => {
+ const response = {
+ tiers: [
+ { name: "community", dailyRequestLimit: 50, monthlyRequestLimit: 250, description: "" },
+ { name: "msp", dailyRequestLimit: 1500, monthlyRequestLimit: 15000, description: "" },
+ ],
+ catalog: [
+ { edition: "community", dailyRequestLimit: 100, monthlyRequestLimit: 3000, tenantDailyRequestLimit: 300, tenantMonthlyRequestLimit: 9000, includedDelegatedSlots: 0, slotDailyRequestLimit: 0, slotMonthlyRequestLimit: 0, slotTenantDailyRequestLimit: 0, slotTenantMonthlyRequestLimit: 0 },
+ ],
+ } as PlanTierDefinitionsResponse;
+
+ expect(planLegend(response)).toEqual([
+ { name: "community", daily: 50, monthly: 250, tenantDaily: 300, tenantMonthly: 9000 },
+ { name: "msp", daily: 1500, monthly: 15000, tenantDaily: undefined, tenantMonthly: undefined },
+ ]);
+ expect(planLegend(null)).toEqual([]);
+ });
+});
+
+describe("formatting", () => {
+ it("formats durations, sizes and shares", () => {
+ expect(formatDuration(95)).toBe("95 ms");
+ expect(formatDuration(812)).toBe("812 ms");
+ expect(formatDuration(1029)).toBe("1.0 s");
+ expect(formatDuration(undefined)).toBe("—");
+ expect(formatChars(167)).toBe("167 B");
+ expect(formatChars(19277)).toBe("19.3 KB");
+ expect(formatShare(0)).toBe("0 %");
+ expect(formatShare(0.0321)).toBe("3.2 %");
+ expect(formatShare(0.25)).toBe("25 %");
+ expect(formatShare(undefined)).toBe("—");
+ });
+
+ it("leaves anything but a yyyyMMdd day unchanged", () => {
+ expect(formatDayKey("2026-11-05")).toBe("2026-11-05");
+ expect(formatDayKey("20261105")).not.toBe("20261105");
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/LimitsTab.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/LimitsTab.tsx
new file mode 100644
index 000000000..bf93f7004
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/LimitsTab.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useState } from "react";
+import { QuotaBar } from "@/components/QuotaBar";
+import { applicationIdOf } from "@/lib/principalKeys";
+import type {
+ McpUsageMinuteLimits,
+ McpUsageQuotaHitItem,
+ McpUsageTelemetryStatus,
+} from "@/lib/generated/wire-types.generated";
+import { formatDayKey, minuteRowsToShow, minuteShare, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
+import { DataNote, Pill } from "./parts";
+
+function limitText(value: number | undefined): string {
+ if (value == null) return "edition default";
+ return value <= 0 ? "unlimited" : value.toLocaleString();
+}
+
+function timeOfDay(iso: string | undefined): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
+}
+
+function Principal({ principal }: { principal: string }) {
+ const appId = applicationIdOf(principal);
+ return (
+
+ {appId && App }
+ {appId ?? principal}
+
+ );
+}
+
+export function LimitsTab({
+ quotaHits,
+ minuteLimits,
+ telemetry,
+ plans,
+ info,
+}: {
+ quotaHits: readonly McpUsageQuotaHitItem[];
+ minuteLimits: McpUsageMinuteLimits;
+ telemetry: McpUsageTelemetryStatus;
+ plans: readonly PlanLegendRow[];
+ info: ReadonlyMap;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function QuotaSection({
+ quotaHits,
+ telemetry,
+ plans,
+ info,
+}: {
+ quotaHits: readonly McpUsageQuotaHitItem[];
+ telemetry: McpUsageTelemetryStatus;
+ plans: readonly PlanLegendRow[];
+ info: ReadonlyMap;
+}) {
+ return (
+
+
+
Daily and monthly quota
+
+ {plans.length > 0 && (
+
+
+
+
+ Plan
+ Account per day / month
+ Organization per day / month
+
+
+
+ {plans.map((plan) => (
+
+ {plan.name}
+ {limitText(plan.daily)} / {limitText(plan.monthly)}
+ {limitText(plan.tenantDaily)} / {limitText(plan.tenantMonthly)}
+
+ ))}
+
+
+
+ )}
+ {!telemetry.requestsAvailable ? (
+
The request telemetry did not answer: the refusals are missing.
+ ) : quotaHits.length === 0 ? (
+
No request was refused by a daily or monthly quota in this window.
+ ) : (
+
+ {quotaHits.map((hit) => (
+
+ {formatDayKey(hit.date)}
+
+ {hit.principal ? : "—"}
+ {tenantLabel(hit.tenantId, info)}
+
+ {hit.level === "tenant" ? "Organization" : "Account"} · {hit.scope}
+ plan {hit.plan}
+
+ {hit.used?.toLocaleString() ?? "?"} / {hit.limit?.toLocaleString() ?? "?"}
+
+
+ {hit.refused === 1 ? "1 request refused" : `${hit.refused.toLocaleString()} requests refused`}, {timeOfDay(hit.firstAt)}
+ {hit.lastAt !== hit.firstAt && ` – ${timeOfDay(hit.lastAt)}`}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function MinuteSection({
+ minuteLimits,
+ telemetry,
+ info,
+}: {
+ minuteLimits: McpUsageMinuteLimits;
+ telemetry: McpUsageTelemetryStatus;
+ info: ReadonlyMap;
+}) {
+ const [showAll, setShowAll] = useState(false);
+ const mcpLimit = minuteLimits.mcpServerLimit;
+ const close = minuteRowsToShow(minuteLimits.accounts, mcpLimit);
+ const rows = showAll ? minuteLimits.accounts : close;
+
+ return (
+
+
+
Per-minute limits
+
+ MCP server: {mcpLimit ? `${mcpLimit} messages per minute per account` : "limit not reported yet"} · Backend: per account, shown in each row
+
+ {minuteLimits.accounts.length > close.length && (
+
+ setShowAll(e.target.checked)} className="rounded border-gray-300" />
+ Show all {minuteLimits.accounts.length} accounts
+
+ )}
+
+ {!telemetry.requestsAvailable &&
The request telemetry did not answer: the peaks and the backend refusals are missing. }
+ {!telemetry.mcpLogAvailable &&
The MCP log did not answer: the MCP server's refusals and its limit are missing. }
+ {rows.length === 0 ? (
+
+ {minuteLimits.accounts.length === 0
+ ? "No MCP requests in this window."
+ : "No account came within half of a per-minute limit, and none was refused."}
+
+ ) : (
+
+ {rows.map((row) => (
+
+
+
+
+ {tenantLabel(row.tenantId, info)} · busiest 60 s at {minuteShare(row, mcpLimit)} % of a limit
+
+
+
+ {mcpLimit ? (
+
+ ) : (
+
Tool calls / 60 s: {row.peakCalls.toLocaleString()}
+ )}
+ {row.mcpRefused > 0 && (
+
{row.mcpRefused.toLocaleString()} refused by the MCP server, last {timeOfDay(row.mcpLastRefusedAt)}
+ )}
+
+
+
+ {row.backendRefused > 0 && (
+
{row.backendRefused.toLocaleString()} refused by the backend, last {timeOfDay(row.backendLastRefusedAt)}
+ )}
+
+ {timeOfDay(row.peakRequestsAt ?? row.peakCallsAt)}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/TenantsTab.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/TenantsTab.tsx
new file mode 100644
index 000000000..e2eef5d17
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/TenantsTab.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { useDeferredValue, useMemo, useState } from "react";
+import Link from "next/link";
+import { SearchInput } from "@/components/SearchInput";
+import { QuotaBar } from "@/components/QuotaBar";
+import { applicationIdOf } from "@/lib/principalKeys";
+import { tenantManagementUrl } from "@/lib/routes";
+import type { McpUsageAccountItem, McpUsageTenantItem } from "@/lib/generated/wire-types.generated";
+import {
+ formatDayKey,
+ searchTenants,
+ sortAccounts,
+ sortTenants,
+ type SortDir,
+ type TenantInfo,
+ type UsageSortKey,
+} from "./mcpUsageLogic";
+import { Chevron, EditionBadge, Figure, HitsPill, Pill, SortHeader } from "./parts";
+import { useOpenSet, useSortState } from "./useListState";
+
+// One grid for the header and every tenant row from lg up; below lg the rows wrap into cards.
+const COLS = "lg:grid lg:grid-cols-[minmax(0,1fr)_4.5rem_4.5rem_5rem_9rem_9rem_7rem] lg:items-center lg:gap-4";
+
+const DEFAULT_DIR: Record = { calls: "desc", requests: "desc", today: "desc", month: "desc", lastActive: "desc" };
+
+function formatLastActive(iso: string | undefined): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
+}
+
+/** The plan an edition gets without an override — anything else is worth naming next to the badge. */
+function isDefaultPlan(plan: string, info: TenantInfo | undefined): boolean {
+ if (!info) return true;
+ return plan === (info.edition === "community" ? "community" : "pro");
+}
+
+export function TenantsTab({ tenants, info }: { tenants: readonly McpUsageTenantItem[]; info: ReadonlyMap }) {
+ const [query, setQuery] = useState("");
+ const deferredQuery = useDeferredValue(query);
+ const [nearLimitOnly, setNearLimitOnly] = useState(false);
+ const { sortKey, sortDir, onSort } = useSortState("calls", DEFAULT_DIR);
+ const { open, toggle } = useOpenSet();
+
+ const matches = useMemo(
+ () => searchTenants(sortTenants(tenants, sortKey, sortDir), info, deferredQuery, nearLimitOnly),
+ [tenants, info, deferredQuery, nearLimitOnly, sortKey, sortDir],
+ );
+
+ const searching = deferredQuery.trim().length > 0;
+
+ return (
+
+
+
+
+ setNearLimitOnly(e.target.checked)} className="rounded border-gray-300" />
+ Near a limit
+
+
+ {matches.length === tenants.length ? `${tenants.length} tenants` : `${matches.length} of ${tenants.length} tenants`}
+
+
+
+
+ Tenant
+ Accounts
+ Calls
+ Requests
+ Today
+ Month
+ Last active
+
+
+ {matches.length === 0 ? (
+
+ {tenants.length === 0
+ ? "No MCP use in this window."
+ : searching
+ ? `No tenant, user or app matches “${deferredQuery.trim()}”.`
+ : "No tenant is near a limit."}
+
+ ) : (
+
+ {matches.map(({ tenant, accounts, matchedByAccount }) => {
+ const t = info.get(tenant.tenantId.toLowerCase());
+ const isOpen = open.has(tenant.tenantId) || matchedByAccount;
+ const apps = tenant.accounts.filter((a) => applicationIdOf(a.principal)).length;
+ return (
+
+ toggle(tenant.tenantId)}
+ aria-expanded={isOpen}
+ className={`w-full text-left px-4 sm:px-6 py-3 hover:bg-gray-50 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}
+ >
+
+
+
+
+
+ {t?.domainName ?? tenant.tenantId}
+
+ {t &&
}
+ {!isDefaultPlan(tenant.quota.tenantPlan, t) &&
plan {tenant.quota.tenantPlan} }
+ {t?.mcpDisabled &&
MCP closed }
+
+
+ {t?.companyName &&
{t.companyName}
}
+
+
+ 0 ? `${tenant.accounts.length - apps} + ${apps} app${apps === 1 ? "" : "s"}` : String(tenant.accounts.length)} />
+
+
+
+
+
+
+
+
+ Last active {formatLastActive(tenant.lastRequestAt)}
+
+ {isOpen && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function AccountList({ accounts, tenantPlan, tenantId }: { accounts: readonly McpUsageAccountItem[]; tenantPlan: string; tenantId: string }) {
+ return (
+
+
+ {accounts.map((account) => (
+
+ ))}
+
+
+
+ Open in Tenant Management
+
+
+
+ );
+}
+
+function AccountRow({ account, tenantPlan }: { account: McpUsageAccountItem; tenantPlan: string }) {
+ const appId = applicationIdOf(account.principal);
+ return (
+
+
+
+ {appId &&
App }
+
+ {appId ?? account.principal}
+
+ {account.role &&
{account.role} }
+ {account.isPlatformAdmin &&
platform }
+ {account.quota && account.quota.plan !== tenantPlan &&
plan {account.quota.plan} }
+
+
+
+ {account.topTools.map((t) => (
+
+ {t.tool} {t.calls}
+
+ ))}
+ {account.peakDay && (
+
+ busiest day {formatDayKey(account.peakDay)} with {account.peakDayRequests?.toLocaleString()} requests
+
+ )}
+
+
+
+
+
+
+ {account.quota ? : no quota state }
+
+
+ {account.quota && }
+
+ Last active {formatLastActive(account.lastRequestAt)}
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/ToolsTab.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/ToolsTab.tsx
new file mode 100644
index 000000000..e2f1022e4
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/ToolsTab.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useMemo } from "react";
+import type { McpUsageTelemetryStatus, McpUsageToolItem } from "@/lib/generated/wire-types.generated";
+import { formatChars, formatDayKey, formatDuration, formatShare, sortTools, type SortDir, type ToolSortKey } from "./mcpUsageLogic";
+import { Chevron, DataNote, Figure, Pill, SortHeader } from "./parts";
+import { useOpenSet, useSortState } from "./useListState";
+
+const COLS = "lg:grid lg:grid-cols-[minmax(0,1fr)_10rem_4.5rem_4.5rem_6rem_4.5rem_4.5rem_5rem_4.5rem] lg:items-center lg:gap-4";
+
+const DEFAULT_DIR: Record = { calls: "desc", requestsPerCall: "desc", errorRate: "desc", p95: "desc", size: "desc" };
+
+export function ToolsTab({
+ tools,
+ telemetry,
+ dateTo,
+}: {
+ tools: readonly McpUsageToolItem[];
+ telemetry: McpUsageTelemetryStatus;
+ dateTo: string;
+}) {
+ const { sortKey, sortDir, onSort } = useSortState("calls", DEFAULT_DIR);
+ const { open, toggle } = useOpenSet();
+
+ const sorted = useMemo(() => sortTools(tools, sortKey, sortDir), [tools, sortKey, sortDir]);
+ const maxCalls = Math.max(1, ...tools.map((t) => t.calls));
+
+ return (
+
+ {!telemetry.requestsAvailable && (
+
The request telemetry did not answer: calls, reach and the cost per call are missing.
+ )}
+ {!telemetry.mcpLogAvailable && (
+
The MCP log did not answer: error rate, duration, answer size and the MCP-only tools are missing.
+ )}
+ {telemetry.mcpLogFrom && (
+
+ Error rate, duration, answer size and the MCP-only tools cover {formatDayKey(telemetry.mcpLogFrom)} – {formatDayKey(dateTo)}.
+
+ )}
+
+
+ Tool
+ Calls
+ Accounts
+ Tenants
+
+ Req./call
+
+
+ Errors
+
+
+ p95
+
+
+ Size
+
+
+ Over cap
+
+
+
+ {sorted.length === 0 ? (
+
No tool calls in this window.
+ ) : (
+
+ {sorted.map((tool) => {
+ const mcpOnly = tool.accounts == null;
+ const expandable = tool.routes.length > 0;
+ const isOpen = expandable && open.has(tool.tool);
+ const share = Math.max(2, Math.round((tool.calls / maxCalls) * 100));
+ const row = (
+ <>
+
+ {expandable ?
:
}
+
{tool.tool}
+ {mcpOnly &&
MCP only }
+
+
+
+
{tool.calls.toLocaleString()}
+
+
+
+
+ = 0.05 ? "warn" : undefined} />
+
+
+
+ >
+ );
+ return (
+
+ {expandable ? (
+ toggle(tool.tool)}
+ aria-expanded={isOpen}
+ className={`w-full text-left px-4 sm:px-6 py-3 hover:bg-gray-50 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}
+ >
+ {row}
+
+ ) : (
+ {row}
+ )}
+ {isOpen && (
+
+
Backend requests of its calls
+
+ {tool.routes.map((route) => (
+
+ {route.route}
+ {route.requests.toLocaleString()}
+
+ ))}
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/mcpUsageLogic.ts b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/mcpUsageLogic.ts
new file mode 100644
index 000000000..0a97e929b
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/mcpUsageLogic.ts
@@ -0,0 +1,323 @@
+/**
+ * Pure folds behind the GA MCP Usage page (/admin/metrics/mcp-usage). The route
+ * (GET global/metrics/mcp-usage/overview) delivers the numbers; this module only joins tenant names,
+ * filters, sorts and formats. "Calls" are tool calls, "requests" are backend requests charged to the
+ * quota — the page never mixes the two in one figure.
+ */
+import { effectiveEdition, type PlanEdition } from "../../../components/tenantFilters";
+import { isApplicationKey } from "@/lib/principalKeys";
+import type {
+ GetGlobalMcpUsageOverviewResponse,
+ McpUsageAccountItem,
+ McpUsageMinuteItem,
+ McpUsageTenantItem,
+ McpUsageToolItem,
+ PlanTierDefinitionsResponse,
+ TenantConfiguration,
+} from "@/lib/generated/wire-types.generated";
+
+// ── View (?view=) ─────────────────────────────────────────────────────────────
+
+export type McpUsageView = "tenants" | "tools" | "limits";
+
+export const MCP_USAGE_VIEW_PARAM = "view";
+
+/** The tab a link asks for; anything unknown is the default tab. */
+export function parseView(raw: string | null | undefined): McpUsageView {
+ return raw === "tools" || raw === "limits" ? raw : "tenants";
+}
+
+/** The telemetry keeps 90 days; a longer ?days= window shows 90. */
+export const MCP_USAGE_MAX_DAYS = 90;
+
+// ── Tenant names ──────────────────────────────────────────────────────────────
+
+/** What the page shows about a tenant beyond the overview: name and edition from config/all. */
+export interface TenantInfo {
+ domainName?: string;
+ companyName?: string;
+ edition: PlanEdition;
+ mcpDisabled: boolean;
+}
+
+/** config/all by lower-cased tenant id — the overview's ids are lower-cased too. */
+export function tenantInfoMap(tenants: readonly TenantConfiguration[], nowMs: number): Map {
+ const map = new Map();
+ for (const t of tenants) {
+ map.set(t.tenantId.toLowerCase(), {
+ domainName: t.domainName || undefined,
+ companyName: t.companyName || undefined,
+ edition: effectiveEdition(t, nowMs),
+ mcpDisabled: !!t.mcpDisabled,
+ });
+ }
+ return map;
+}
+
+/** Display name of a tenant: its domain, else the first block of its id. */
+export function tenantLabel(tenantId: string, info: ReadonlyMap): string {
+ return info.get(tenantId.toLowerCase())?.domainName ?? `${tenantId.slice(0, 8)}…`;
+}
+
+// ── Windows and limits ────────────────────────────────────────────────────────
+
+/** Share of a window in whole percent; null when the window is unlimited (0) or the counter was unavailable (−1). */
+export function usagePercent(used: number, limit: number): number | null {
+ if (limit <= 0 || used < 0) return null;
+ return Math.round((used / limit) * 100);
+}
+
+/** The bars turn amber here (QuotaBar) — the same threshold marks "near a limit". */
+export const NEAR_LIMIT_PERCENT = 80;
+
+interface WindowPair {
+ dailyUsed: number;
+ dailyLimit: number;
+ monthlyUsed: number;
+ monthlyLimit: number;
+}
+
+function dailyShare(q: WindowPair | undefined): number {
+ return q ? usagePercent(q.dailyUsed, q.dailyLimit) ?? -1 : -1;
+}
+
+function monthlyShare(q: WindowPair | undefined): number {
+ return q ? usagePercent(q.monthlyUsed, q.monthlyLimit) ?? -1 : -1;
+}
+
+export function accountNearLimit(a: McpUsageAccountItem): boolean {
+ return a.quotaHits > 0 || Math.max(dailyShare(a.quota), monthlyShare(a.quota)) >= NEAR_LIMIT_PERCENT;
+}
+
+export function tenantNearLimit(t: McpUsageTenantItem): boolean {
+ return t.quotaHits > 0
+ || Math.max(dailyShare(t.quota), monthlyShare(t.quota)) >= NEAR_LIMIT_PERCENT
+ || t.accounts.some(accountNearLimit);
+}
+
+// ── Sorting ───────────────────────────────────────────────────────────────────
+
+export type UsageSortKey = "calls" | "requests" | "today" | "month" | "lastActive";
+export type SortDir = "asc" | "desc";
+
+function lastActiveMs(iso: string | undefined): number {
+ return iso ? new Date(iso).getTime() : -1;
+}
+
+function sortValue(item: { calls: number; requests: number; lastRequestAt?: string }, quota: WindowPair | undefined, key: UsageSortKey): number {
+ switch (key) {
+ case "calls": return item.calls;
+ case "requests": return item.requests;
+ case "today": return dailyShare(quota);
+ case "month": return monthlyShare(quota);
+ case "lastActive": return lastActiveMs(item.lastRequestAt);
+ }
+}
+
+function compare(a: number, b: number, dir: SortDir): number {
+ return dir === "asc" ? a - b : b - a;
+}
+
+export function sortTenants(tenants: readonly McpUsageTenantItem[], key: UsageSortKey, dir: SortDir): McpUsageTenantItem[] {
+ return [...tenants].sort((a, b) => compare(sortValue(a, a.quota, key), sortValue(b, b.quota, key), dir) || b.calls - a.calls);
+}
+
+export function sortAccounts(accounts: readonly McpUsageAccountItem[], key: UsageSortKey, dir: SortDir): McpUsageAccountItem[] {
+ return [...accounts].sort((a, b) => compare(sortValue(a, a.quota, key), sortValue(b, b.quota, key), dir) || b.calls - a.calls);
+}
+
+export type ToolSortKey = "calls" | "requestsPerCall" | "errorRate" | "p95" | "size";
+
+/** Tools by a column; a figure the MCP log did not deliver sorts below every real value. */
+export function sortTools(tools: readonly McpUsageToolItem[], key: ToolSortKey, dir: SortDir): McpUsageToolItem[] {
+ const value = (t: McpUsageToolItem): number => {
+ switch (key) {
+ case "calls": return t.calls;
+ case "requestsPerCall": return t.requestsPerCall;
+ case "errorRate": return t.errorRate ?? -1;
+ case "p95": return t.p95DurationMs ?? -1;
+ case "size": return t.avgResultChars ?? -1;
+ }
+ };
+ return [...tools].sort((a, b) => compare(value(a), value(b), dir) || b.calls - a.calls || a.tool.localeCompare(b.tool));
+}
+
+// ── Search ────────────────────────────────────────────────────────────────────
+
+export interface TenantMatch {
+ tenant: McpUsageTenantItem;
+ /** The accounts to list: all of them when the tenant matched, else only the matching ones. */
+ accounts: McpUsageAccountItem[];
+ /** The query matched an account, not the tenant — the row opens by itself. */
+ matchedByAccount: boolean;
+}
+
+/**
+ * Tenants matching the query by domain, company or id; or holding an account whose principal (UPN or
+ * app:) matches. Case-insensitive substring; an empty query keeps everything.
+ */
+export function searchTenants(
+ tenants: readonly McpUsageTenantItem[],
+ info: ReadonlyMap,
+ query: string,
+ nearLimitOnly: boolean,
+): TenantMatch[] {
+ const q = query.trim().toLowerCase();
+ const result: TenantMatch[] = [];
+ for (const tenant of tenants) {
+ if (nearLimitOnly && !tenantNearLimit(tenant)) continue;
+ const accounts = nearLimitOnly ? tenant.accounts.filter(accountNearLimit) : tenant.accounts;
+ if (!q) {
+ result.push({ tenant, accounts: [...accounts], matchedByAccount: false });
+ continue;
+ }
+ const t = info.get(tenant.tenantId.toLowerCase());
+ const tenantHit = [tenant.tenantId, t?.domainName, t?.companyName].some((v) => v?.toLowerCase().includes(q));
+ if (tenantHit) {
+ result.push({ tenant, accounts: [...accounts], matchedByAccount: false });
+ continue;
+ }
+ const hits = accounts.filter((a) => a.principal.toLowerCase().includes(q));
+ if (hits.length > 0) result.push({ tenant, accounts: hits, matchedByAccount: true });
+ }
+ return result;
+}
+
+// ── Totals (the tiles) ────────────────────────────────────────────────────────
+
+export interface OverviewTotals {
+ calls: number;
+ /** Calls of tools that run in the MCP server alone — counted in `calls`, attributed to no account. */
+ mcpOnlyCalls: number;
+ toolCount: number;
+ requests: number;
+ tenants: number;
+ communityTenants: number;
+ proTenants: number;
+ accounts: number;
+ apps: number;
+ /** Accounts that met any limit in the window. */
+ limitAccounts: number;
+ quotaHitAccounts: number;
+ minuteRefusedAccounts: number;
+}
+
+export function overviewTotals(overview: GetGlobalMcpUsageOverviewResponse, info: ReadonlyMap): OverviewTotals {
+ const accounts = overview.tenants.flatMap((t) => t.accounts);
+ const editionOf = (tenantId: string) => info.get(tenantId.toLowerCase())?.edition;
+ const quotaHit = new Set(overview.quotaHits.map((h) => `${h.tenantId}|${h.principal ?? ""}`));
+ const minuteRefused = new Set(
+ overview.minuteLimits.accounts.filter((m) => m.mcpRefused > 0 || m.backendRefused > 0).map((m) => `${m.tenantId}|${m.principal}`),
+ );
+ return {
+ calls: overview.tools.reduce((sum, t) => sum + t.calls, 0),
+ mcpOnlyCalls: overview.tools.filter((t) => t.accounts == null).reduce((sum, t) => sum + t.calls, 0),
+ toolCount: overview.tools.length,
+ requests: overview.tenants.reduce((sum, t) => sum + t.requests, 0),
+ tenants: overview.tenants.length,
+ communityTenants: overview.tenants.filter((t) => editionOf(t.tenantId) === "community").length,
+ proTenants: overview.tenants.filter((t) => editionOf(t.tenantId)?.startsWith("pro")).length,
+ accounts: accounts.length,
+ apps: accounts.filter((a) => isApplicationKey(a.principal)).length,
+ limitAccounts: new Set([...quotaHit, ...minuteRefused]).size,
+ quotaHitAccounts: quotaHit.size,
+ minuteRefusedAccounts: minuteRefused.size,
+ };
+}
+
+// ── Daily chart ───────────────────────────────────────────────────────────────
+
+export interface DailyChart {
+ rows: Array>;
+ /** Usage plans with calls in the window, most calls first — one stack each. */
+ plans: string[];
+}
+
+/** One row per day: date, calls, requests, accounts and one column per usage plan (its calls). */
+export function dailyChart(overview: GetGlobalMcpUsageOverviewResponse): DailyChart {
+ const totals = new Map();
+ for (const day of overview.daily) for (const p of day.callsByPlan) totals.set(p.plan, (totals.get(p.plan) ?? 0) + p.calls);
+ const plans = [...totals.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([plan]) => plan);
+ const rows = overview.daily.map((day) => {
+ const row: Record = { date: day.date, calls: day.calls, requests: day.requests, accounts: day.accounts };
+ for (const plan of plans) row[`plan:${plan}`] = day.callsByPlan.find((p) => p.plan === plan)?.calls ?? 0;
+ return row;
+ });
+ return { rows, plans };
+}
+
+// ── Per-minute limits ─────────────────────────────────────────────────────────
+
+/** A peak counts as close from here; below it an account only shows with a refusal. */
+export const MINUTE_SHOW_PERCENT = 50;
+
+/** The higher of an account's two per-minute shares (MCP server, backend), in whole percent. */
+export function minuteShare(row: McpUsageMinuteItem, mcpLimit: number | undefined): number {
+ const mcp = mcpLimit && mcpLimit > 0 ? (row.peakCalls / mcpLimit) * 100 : 0;
+ const backend = row.backendLimit > 0 ? (row.peakRequests / row.backendLimit) * 100 : 0;
+ return Math.round(Math.max(mcp, backend));
+}
+
+/** Rows worth a look: a refusal, or a peak at half a limit or more. */
+export function minuteRowsToShow(rows: readonly McpUsageMinuteItem[], mcpLimit: number | undefined): McpUsageMinuteItem[] {
+ return rows.filter((r) => r.mcpRefused > 0 || r.backendRefused > 0 || minuteShare(r, mcpLimit) >= MINUTE_SHOW_PERCENT);
+}
+
+// ── Plan legend ───────────────────────────────────────────────────────────────
+
+export interface PlanLegendRow {
+ name: string;
+ daily: number;
+ monthly: number;
+ /** Organization windows; absent when the plan leaves them to the tenant's edition. */
+ tenantDaily?: number;
+ tenantMonthly?: number;
+}
+
+/**
+ * The base windows of each usage plan (0 = unlimited). A blank organization window of the community
+ * or pro plan falls back to that edition's catalog value; another plan's blank window follows the
+ * tenant's edition, so it stays open here.
+ */
+export function planLegend(response: PlanTierDefinitionsResponse | null): PlanLegendRow[] {
+ if (!response) return [];
+ return response.tiers.map((tier) => {
+ const catalog = response.catalog.find((c) => c.edition === tier.name.toLowerCase());
+ return {
+ name: tier.name,
+ daily: tier.dailyRequestLimit,
+ monthly: tier.monthlyRequestLimit,
+ tenantDaily: tier.tenantDailyRequestLimit ?? catalog?.tenantDailyRequestLimit,
+ tenantMonthly: tier.tenantMonthlyRequestLimit ?? catalog?.tenantMonthlyRequestLimit,
+ };
+ });
+}
+
+// ── Formatting ────────────────────────────────────────────────────────────────
+
+/** A UTC day (yyyyMMdd) in the viewer's locale, never shifted by the viewer's time zone. */
+export function formatDayKey(key: string, options: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" }): string {
+ if (!/^\d{8}$/.test(key)) return key;
+ const iso = `${key.slice(0, 4)}-${key.slice(4, 6)}-${key.slice(6, 8)}T00:00:00Z`;
+ return new Date(iso).toLocaleDateString(undefined, { ...options, timeZone: "UTC" });
+}
+
+/** "0.8 s" from 812 ms, "95 ms" below a second, "—" without a value. */
+export function formatDuration(ms: number | undefined): string {
+ if (ms == null) return "—";
+ return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
+}
+
+/** Answer size in KB (one character ≈ one byte of JSON), "—" without a value. */
+export function formatChars(chars: number | undefined): string {
+ if (chars == null) return "—";
+ return chars < 1000 ? `${chars} B` : `${(chars / 1000).toFixed(1)} KB`;
+}
+
+/** A 0–1 share as "12.5 %", "0 %" for none, "—" without a value. */
+export function formatShare(share: number | undefined): string {
+ if (share == null) return "—";
+ if (share === 0) return "0 %";
+ const pct = share * 100;
+ return `${pct < 10 ? pct.toFixed(1) : Math.round(pct)} %`;
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/parts.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/parts.tsx
new file mode 100644
index 000000000..9af597f7a
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/parts.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { editionLabel, facetOption, type PlanEdition } from "../../../components/tenantFilters";
+import type { SortDir } from "./mcpUsageLogic";
+
+/** A disclosure arrow that turns when its row opens. */
+export function Chevron({ open }: { open: boolean }) {
+ return (
+
+
+
+ );
+}
+
+/** A column label that sorts: ▲/▼ on the active column, ↕ on the others. */
+export function SortHeader({
+ sortKey,
+ current,
+ dir,
+ onSort,
+ children,
+ align = "left",
+}: {
+ sortKey: K;
+ current: K;
+ dir: SortDir;
+ onSort: (key: K) => void;
+ children: ReactNode;
+ align?: "left" | "right";
+}) {
+ const active = current === sortKey;
+ return (
+ onSort(sortKey)}
+ className={`inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 ${align === "right" ? "justify-end" : ""}`}
+ >
+ {children}
+
+ {active ? (dir === "asc" ? "▲" : "▼") : "↕"}
+
+
+ );
+}
+
+/** A neutral context label (role, plan, origin) in the gray-outline pill style. */
+export function Pill({ children, title }: { children: ReactNode; title?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** The tenant's edition in the colors the tenant lists use. */
+export function EditionBadge({ edition }: { edition: PlanEdition }) {
+ return (
+
+ {editionLabel(edition)}
+
+ );
+}
+
+/** A count of refusals: red when there were any, nothing otherwise. */
+export function HitsPill({ count, title }: { count: number; title: string }) {
+ if (count <= 0) return null;
+ return (
+
+ {count === 1 ? "1 limit hit" : `${count} limit hits`}
+
+ );
+}
+
+/** A number with its own label below lg, where the column header row is hidden; amber for a figure worth a look. */
+export function Figure({ label, value, tone }: { label: string; value: string; tone?: "warn" }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+/** A grey line naming a gap in the data (a store that did not answer, a log that reaches back less far). */
+export function DataNote({ children }: { children: ReactNode }) {
+ return {children}
;
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/useListState.ts b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/useListState.ts
new file mode 100644
index 000000000..2883cad8d
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/mcpUsage/useListState.ts
@@ -0,0 +1,31 @@
+"use client";
+
+import { useState } from "react";
+import type { SortDir } from "./mcpUsageLogic";
+
+/** A sortable column set: a click on the active column flips the direction, another column starts at its default. */
+export function useSortState(initial: K, defaultDir: Record) {
+ const [sortKey, setSortKey] = useState(initial);
+ const [sortDir, setSortDir] = useState(defaultDir[initial]);
+ const onSort = (key: K) => {
+ if (key === sortKey) {
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
+ } else {
+ setSortKey(key);
+ setSortDir(defaultDir[key]);
+ }
+ };
+ return { sortKey, sortDir, onSort };
+}
+
+/** Which rows of a list are open. */
+export function useOpenSet() {
+ const [open, setOpen] = useState>(() => new Set());
+ const toggle = (id: string) =>
+ setOpen((current) => {
+ const next = new Set(current);
+ if (!next.delete(id)) next.add(id);
+ return next;
+ });
+ return { open, toggle };
+}
diff --git a/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx b/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx
index 8dcedcd92..1250c6689 100644
--- a/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx
@@ -18,6 +18,7 @@ import type {
McpUsageQuotaNode,
} from "@/lib/generated/wire-types.generated";
import { isApplicationKey, principalLabel } from "@/lib/principalKeys";
+import { QuotaBar } from "@/components/QuotaBar";
interface UsageRecord {
userId: string;
@@ -51,33 +52,6 @@ function breakdownLabel(limit: number, slots: number | undefined, perSlot: numbe
return b && `${b.base.toLocaleString()} + ${b.slots} × ${b.perSlot.toLocaleString()}`;
}
-function QuotaBar({ label, used, limit, breakdown }: { label: string; used: number; limit: number; breakdown?: string | null }) {
- const unavailable = used < 0;
- const unlimited = limit <= 0;
- const pct = unavailable || unlimited ? 0 : Math.min(100, Math.round((used / limit) * 100));
- const tone = pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500";
- return (
-
-
- {label}
-
- {unavailable ? "n/a" : used.toLocaleString()}
- {" / "}
- {unlimited ? "unlimited" : limit.toLocaleString()}
- {breakdown && (
-
- {breakdown}
-
- )}
-
-
-
-
- );
-}
-
function formatDate(yyyymmdd: string): string {
if (yyyymmdd.length !== 8) return yyyymmdd;
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
diff --git a/src/Web/autopilot-monitor-web/components/QuotaBar.tsx b/src/Web/autopilot-monitor-web/components/QuotaBar.tsx
new file mode 100644
index 000000000..2c658869a
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/components/QuotaBar.tsx
@@ -0,0 +1,31 @@
+/**
+ * One MCP quota window as a bar: used of limit, indigo up to 80 %, amber from 80 %, red at the limit.
+ * A used value below 0 means the counter was unavailable ("n/a"); a limit of 0 means unlimited. The
+ * optional breakdown names the plan base plus purchased delegation slots behind a grown limit.
+ */
+export function QuotaBar({ label, used, limit, breakdown }: { label: string; used: number; limit: number; breakdown?: string | null }) {
+ const unavailable = used < 0;
+ const unlimited = limit <= 0;
+ const pct = unavailable || unlimited ? 0 : Math.min(100, Math.round((used / limit) * 100));
+ const tone = pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500";
+ return (
+
+
+ {label}
+
+ {unavailable ? "n/a" : used.toLocaleString()}
+ {" / "}
+ {unlimited ? "unlimited" : limit.toLocaleString()}
+ {breakdown && (
+
+ {breakdown}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx b/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx
index ebe96c4ea..06daf442c 100644
--- a/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx
+++ b/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx
@@ -12,6 +12,7 @@ import {
Cell,
} from "recharts";
import { chartColors, tooltipStyle, axisTick, axisLine } from "./chartTheme";
+import { RenderedTooltip } from "./RenderedTooltip";
export interface AppBarChartProps {
data: Array>;
@@ -33,20 +34,6 @@ export interface AppBarChartProps {
integerTicks?: boolean;
}
-interface RenderedTooltipProps {
- render: (row: Record) => ReactNode;
- // Injected by recharts when it clones the content element.
- active?: boolean;
- payload?: ReadonlyArray<{ payload?: unknown }>;
-}
-
-// Module level on purpose: recharts clones this element per hover, so its type must stay stable.
-function RenderedTooltip({ render, active, payload }: RenderedTooltipProps) {
- const row = active ? payload?.[0]?.payload : undefined;
- if (!row || typeof row !== "object") return null;
- return {render(row as Record)}
;
-}
-
/**
* Reusable bar chart used by the App Dashboard detail page and the GA Customers section.
* Used for "Top Failure Codes" and "Installer Phase Breakdown" etc.
diff --git a/src/Web/autopilot-monitor-web/components/charts/AppStackedBarChart.tsx b/src/Web/autopilot-monitor-web/components/charts/AppStackedBarChart.tsx
index f55002e2c..68b9b0726 100644
--- a/src/Web/autopilot-monitor-web/components/charts/AppStackedBarChart.tsx
+++ b/src/Web/autopilot-monitor-web/components/charts/AppStackedBarChart.tsx
@@ -1,5 +1,6 @@
"use client";
+import type { ReactNode } from "react";
import {
ResponsiveContainer,
BarChart,
@@ -11,6 +12,7 @@ import {
Legend,
} from "recharts";
import { chartColors, tooltipStyle, axisTick, axisLine, legendStyle } from "./chartTheme";
+import { RenderedTooltip } from "./RenderedTooltip";
export interface StackedSeries {
dataKey: string;
@@ -24,6 +26,8 @@ export interface AppStackedBarChartProps {
stacks: StackedSeries[];
height?: number;
formatXTick?: (value: unknown) => string;
+ /** Replaces the tooltip content for the hovered row. */
+ renderTooltip?: (row: Record) => ReactNode;
}
/**
@@ -39,6 +43,7 @@ export default function AppStackedBarChart({
stacks,
height = 260,
formatXTick,
+ renderTooltip,
}: AppStackedBarChartProps) {
return (
@@ -64,6 +69,7 @@ export default function AppStackedBarChart({
labelStyle={{ color: chartColors.tooltipText, marginBottom: 4 }}
cursor={{ fill: chartColors.gridLine, opacity: 0.3 }}
labelFormatter={formatXTick ? (l: unknown) => formatXTick(l) : undefined}
+ {...(renderTooltip ? { content: } : {})}
/>
{stacks.map((s) => (
diff --git a/src/Web/autopilot-monitor-web/components/charts/RenderedTooltip.tsx b/src/Web/autopilot-monitor-web/components/charts/RenderedTooltip.tsx
new file mode 100644
index 000000000..b2b2e8940
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/components/charts/RenderedTooltip.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { tooltipStyle } from "./chartTheme";
+
+interface RenderedTooltipProps {
+ render: (row: Record) => ReactNode;
+ // Injected by recharts when it clones the content element.
+ active?: boolean;
+ payload?: ReadonlyArray<{ payload?: unknown }>;
+}
+
+/**
+ * Tooltip content that hands the hovered row to a caller's render function. Module level on purpose:
+ * recharts clones this element per hover, so its type must stay stable.
+ */
+export function RenderedTooltip({ render, active, payload }: RenderedTooltipProps) {
+ const row = active ? payload?.[0]?.payload : undefined;
+ if (!row || typeof row !== "object") return null;
+ return {render(row as Record)}
;
+}
diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts
index 91849ee20..bbca8908e 100644
--- a/src/Web/autopilot-monitor-web/lib/api.ts
+++ b/src/Web/autopilot-monitor-web/lib/api.ts
@@ -834,10 +834,9 @@ export const api = {
/** One tenant's organization budget by account for a GA / Global Reader; tenantId is required (no aggregate). */
globalOrganization: (tenantId: string, dateFrom?: string, dateTo?: string) =>
`${API_BASE_URL}/api/global/metrics/mcp-usage/organization${qs({ tenantId, dateFrom, dateTo })}`,
- global: (tenantId?: string, dateFrom?: string, dateTo?: string) =>
- `${API_BASE_URL}/api/global/metrics/mcp-usage${qs({ tenantId, dateFrom, dateTo })}`,
- daily: (tenantId?: string, dateFrom?: string, dateTo?: string) =>
- `${API_BASE_URL}/api/global/metrics/mcp-usage/daily${qs({ tenantId, dateFrom, dateTo })}`,
+ /** All-tenant overview of the GA metrics page: tenants, accounts, tools, refusals, per-minute peaks over 1–90 days. */
+ overview: (days: number, platformAdmins: "include" | "exclude") =>
+ `${API_BASE_URL}/api/global/metrics/mcp-usage/overview${qs({ days: String(days), platformAdmins })}`,
planTiers: () => `${API_BASE_URL}/api/global/config/plan-tiers`,
},
diff --git a/tools/quality/jscpd-baseline.json b/tools/quality/jscpd-baseline.json
index 9cd71b817..59d0853cc 100644
--- a/tools/quality/jscpd-baseline.json
+++ b/tools/quality/jscpd-baseline.json
@@ -50,7 +50,6 @@
"a3b1b3898c247667": 1,
"a777f9e57bac53d5": 1,
"a8ea1228aa75b506": 1,
- "ab6861a9ea29854d": 1,
"af270234f673d876": 1,
"b26235044348e7ba": 1,
"b62f5f7b96567483": 1,