MCP Usage page (GA): tenants, tools and limits at a glance

The Global Admin MCP Usage page reads the overview route. The header has
the window, a switch for platform admins and Refresh; tiles count tool
calls, charged requests, tenants, accounts and the accounts at a limit;
a chart stacks the calls per day by usage plan. Three tabs in ?view=:
- Tenants & accounts: grouped by tenant with domain, edition and the
  quota bars for today and the month, expandable to accounts and service
  principals, with search and a near-a-limit filter
- Tools: ranked by calls with reach, requests per call, error rate, p95,
  answer size and over-cap calls; the backend routes on expand
- Limits: the plan legend, the quota refusals, and each account's
  busiest 60 seconds against the MCP server's and the backend's
  per-minute limits

QuotaBar moves to components (the reporting page uses it unchanged), the
stacked bar chart takes a custom tooltip like the bar chart, and the
global and daily usage URLs leave the web API client (the routes stay
for the MCP tool).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-11-05 19:00:49 +01:00
parent cdd44d152a
commit 3f97a418d4
16 changed files with 1544 additions and 265 deletions
@@ -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;
@@ -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 <div className="flex h-56 items-center justify-center text-sm text-gray-400">Loading chart…</div>;
}
/** 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<string, string> = { community: chartColors.success, pro: chartColors.primary, msp: chartColors.warning };
const NO_TENANT_INFO: ReadonlyMap<string, TenantInfo> = 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 (
<Suspense fallback={<div className="min-h-screen bg-gray-50" />}>
<McpUsageContent />
</Suspense>
);
}
function McpUsageContent() {
const { getAccessToken } = useAuth();
const [records, setRecords] = useState<UsageRecord[]>([]);
const [dailySummaries, setDailySummaries] = useState<DailySummary[]>([]);
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<GetGlobalMcpUsageOverviewResponse | null>(null);
const [tenantRows, setTenantRows] = useState<{ tenants: TenantConfiguration[]; nowMs: number } | null>(null);
const [planTiers, setPlanTiers] = useState<PlanTierDefinitionsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<GetGlobalMcpUsageResponse>(api.mcpUsage.global(undefined, dateFrom, dateTo), getAccessToken),
fetchJson<GetGlobalMcpUsageDailyResponse>(api.mcpUsage.daily(undefined, dateFrom, dateTo), getAccessToken),
]);
setRecords(globalData.records || []);
setDailySummaries(dailyData.summaries || []);
const data = await fetchJson<GetGlobalMcpUsageOverviewResponse>(
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<TenantConfiguration[]>(api.config.all(), getAccessToken),
fetchJson<PlanTierDefinitionsResponse>(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<string, UserAggregate>();
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.
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">Global MCP Usage</h2>
<p className="text-sm text-gray-500">API usage across all MCP-enabled users</p>
<h2 className="text-lg font-semibold text-gray-900">MCP Usage</h2>
<p className="text-sm text-gray-500">
{overview
? `${formatDayKey(overview.dateFrom, RANGE_DAY)} – ${formatDayKey(overview.dateTo, RANGE_DAY)} · ${overview.includesPlatformAdmins ? "platform admins included" : "customers only"}`
: "MCP usage across all tenants"}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl
options={WINDOW_PRESET_OPTIONS}
value={days}
onChange={setDays}
/>
<label className="inline-flex items-center gap-2 text-sm text-gray-600" title="Global Admins and Global Readers">
<input
type="checkbox"
checked={includePlatformAdmins}
onChange={(e) => setIncludePlatformAdmins(e.target.checked)}
className="rounded border-gray-300"
/>
Include platform admins
</label>
<SegmentedControl options={WINDOW_PRESET_OPTIONS} value={days} onChange={setDays} />
<button
onClick={() => 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
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">{error}</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-2xl sm:text-3xl font-bold text-indigo-600">{todayRequests.toLocaleString()}</div>
{loading ? (
<div className="flex justify-center py-8">
<CalculatingDialog
title="Reading MCP usage"
subtitle="Quota counters and the request and MCP telemetry of every tenant"
elapsedMs={elapsedMs}
estimateMs={estimateMs}
/>
</div>
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<div className="text-sm text-gray-500 mb-1">Total ({days}d)</div>
<div className="text-2xl sm:text-3xl font-bold text-blue-600">{totalRequests.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<div className="text-sm text-gray-500 mb-1">Unique Users</div>
<div className="text-2xl sm:text-3xl font-bold text-green-600">{uniqueUsersTotal}</div>
</div>
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<div className="text-sm text-gray-500 mb-1">Active Days</div>
<div className="text-2xl sm:text-3xl font-bold text-purple-600">{sortedDaily.length}</div>
</div>
</div>
) : overview && totals && chart ? (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<Stat
label="Tool calls"
value={telemetryDark ? "—" : totals.calls.toLocaleString()}
hint={totals.mcpOnlyCalls > 0 ? `${totals.toolCount} tools · ${totals.mcpOnlyCalls.toLocaleString()} MCP-only` : `${totals.toolCount} tools`}
/>
<Stat label="Requests" value={totals.requests.toLocaleString()} hint="charged to the quota" />
<Stat label="Tenants" value={totals.tenants.toLocaleString()} hint={`${totals.communityTenants} Community · ${totals.proTenants} Pro`} />
<Stat
label="Accounts"
value={totals.accounts.toLocaleString()}
hint={`${totals.accounts - totals.apps} people · ${totals.apps} ${totals.apps === 1 ? "app" : "apps"}`}
/>
<button
type="button"
onClick={() => setView("limits")}
title="Show the limits"
className="text-left rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<Stat
label="Limit hits"
value={totals.limitAccounts.toLocaleString()}
hint={`accounts · ${totals.quotaHitAccounts} quota · ${totals.minuteRefusedAccounts} per minute`}
/>
</button>
</div>
{/* Daily Usage Chart */}
{sortedDaily.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-900 mb-4">Daily Request Volume</h3>
<div className="space-y-2">
{sortedDaily.slice(0, 30).map((day) => (
<div key={day.date} className="flex items-center gap-2 sm:gap-3">
<div className="w-20 sm:w-24 text-xs text-gray-500 font-mono shrink-0">
{formatDate(day.date)}
</div>
<div className="flex-1 bg-gray-100 rounded-full h-5 relative">
<div
className="bg-indigo-500 h-5 rounded-full transition-all"
style={{ width: `${Math.max((day.totalRequests / maxDaily) * 100, 2)}%` }}
/>
</div>
<div className="w-12 sm:w-24 text-xs text-gray-600 text-right shrink-0">
{day.totalRequests.toLocaleString()}
<span className="hidden sm:inline"> / {day.uniqueUsers} users</span>
</div>
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-sm font-medium text-gray-500">Calls per day</h3>
{!overview.telemetry.requestsAvailable ? (
<p className="py-10 text-center text-sm text-gray-500">The request telemetry did not answer: the calls per day are missing.</p>
) : chart.plans.length === 0 ? (
<p className="py-10 text-center text-sm text-gray-500">No tool calls in this window.</p>
) : (
<div className="mt-4">
<AppStackedBarChart
data={chart.rows}
xKey="date"
stacks={chart.plans.map((plan) => ({ dataKey: `plan:${plan}`, label: plan, color: PLAN_COLORS[plan] ?? chartColors.muted }))}
height={220}
formatXTick={(value) => formatDayKey(String(value))}
renderTooltip={(row) => <DayTooltip row={row} plans={chart.plans} />}
/>
</div>
))}
)}
</div>
</div>
)}
{/* Top Endpoints (aggregated across all users) */}
{records.length > 0 && (() => {
const endpointTotals = new Map<string, number>();
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 ? (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
<h3 className="text-sm font-medium text-gray-900">Top Endpoints</h3>
</div>
<div className="divide-y divide-gray-200">
{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 (
<div key={endpoint} className="px-4 sm:px-6 py-3 flex items-center justify-between gap-3 hover:bg-gray-50">
<div className="min-w-0">
<div className="text-sm font-mono text-gray-700 truncate">{tool}</div>
{route && (
<div className="text-xs font-mono text-gray-400 truncate">{route}</div>
)}
</div>
<span className="text-sm text-gray-500 shrink-0">
{count.toLocaleString()}
<span className="hidden sm:inline"> requests</span>
</span>
</div>
);
})}
</div>
</div>
) : null;
})()}
<SegmentedControl options={viewOptions} value={view} onChange={setView} className="w-fit max-w-full overflow-x-auto" />
{/* Top Users */}
{userAggregates.length > 0 && (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
<h3 className="text-sm font-medium text-gray-900">Top Users by Request Count</h3>
</div>
<div className="divide-y divide-gray-200">
{userAggregates.slice(0, 25).map((user) => (
<div key={user.userId || user.upn} className="px-4 sm:px-6 py-3 flex items-center justify-between gap-3 hover:bg-gray-50">
<div className="min-w-0">
<div className="text-sm text-gray-900 truncate flex items-center gap-1.5">
<span className="truncate">{user.upn ? principalLabel(user.upn) : user.userId}</span>
{isApplicationKey(user.upn) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 shrink-0" title="Service principal — automation calling with an app-only token">
App
</span>
)}
</div>
<div className="text-xs text-gray-500 truncate">
<span className="font-mono">{user.tenantId ? user.tenantId.slice(0, 8) : "—"}</span>
{" · "}
{user.lastActive ? new Date(user.lastActive).toLocaleDateString() : "—"}
</div>
</div>
<div className="text-sm font-medium text-gray-900 shrink-0">
{user.totalRequests.toLocaleString()}
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{!loading && records.length === 0 && !error && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-500">No MCP usage data found for the selected period.</p>
</div>
)}
{view === "tenants" && <TenantsTab tenants={overview.tenants} info={info} />}
{view === "tools" && <ToolsTab tools={overview.tools} telemetry={overview.telemetry} dateTo={overview.dateTo} />}
{view === "limits" && (
<LimitsTab
quotaHits={overview.quotaHits}
minuteLimits={overview.minuteLimits}
telemetry={overview.telemetry}
plans={plans}
info={info}
/>
)}
</>
) : null}
</div>
);
}
function DayTooltip({ row, plans }: { row: Record<string, unknown>; plans: readonly string[] }) {
const line = (label: string, value: unknown, indent = false) => (
<p key={label} className={`flex justify-between gap-4 ${indent ? "pl-3 text-gray-300" : ""}`}>
<span>{label}</span>
<span className="font-semibold tabular-nums">{Number(value ?? 0).toLocaleString()}</span>
</p>
);
return (
<div className="space-y-1">
<p className="font-medium">{formatDayKey(String(row.date), { weekday: "short", day: "numeric", month: "short" })}</p>
{line("Tool calls", row.calls)}
{plans.map((plan) => line(plan, row[`plan:${plan}`], true))}
{line("Requests", row.requests)}
{line("Accounts", row.accounts)}
</div>
);
}
@@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): McpUsageToolItem {
return { tool: name, calls, accounts: 1, tenants: 1, requestsPerCall: 1, routes: [], ...over };
}
function minute(principal: string, over: Partial<McpUsageMinuteItem> = {}): 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");
});
});
@@ -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 (
<span className="inline-flex min-w-0 items-center gap-1.5">
{appId && <Pill title="Service principal: an application calling with an app-only token">App</Pill>}
<span className={`break-all sm:break-normal sm:truncate ${appId ? "font-mono" : ""}`}>{appId ?? principal}</span>
</span>
);
}
export function LimitsTab({
quotaHits,
minuteLimits,
telemetry,
plans,
info,
}: {
quotaHits: readonly McpUsageQuotaHitItem[];
minuteLimits: McpUsageMinuteLimits;
telemetry: McpUsageTelemetryStatus;
plans: readonly PlanLegendRow[];
info: ReadonlyMap<string, TenantInfo>;
}) {
return (
<div className="space-y-6">
<QuotaSection quotaHits={quotaHits} telemetry={telemetry} plans={plans} info={info} />
<MinuteSection minuteLimits={minuteLimits} telemetry={telemetry} info={info} />
</div>
);
}
function QuotaSection({
quotaHits,
telemetry,
plans,
info,
}: {
quotaHits: readonly McpUsageQuotaHitItem[];
telemetry: McpUsageTelemetryStatus;
plans: readonly PlanLegendRow[];
info: ReadonlyMap<string, TenantInfo>;
}) {
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 sm:px-6 py-3 border-b border-gray-200">
<h3 className="text-sm font-medium text-gray-900">Daily and monthly quota</h3>
</div>
{plans.length > 0 && (
<div className="px-4 sm:px-6 py-3 border-b border-gray-200 overflow-x-auto">
<table className="text-xs text-gray-600">
<thead>
<tr className="text-left text-gray-500">
<th className="pr-6 font-medium">Plan</th>
<th className="pr-6 font-medium">Account per day / month</th>
<th className="font-medium">Organization per day / month</th>
</tr>
</thead>
<tbody>
{plans.map((plan) => (
<tr key={plan.name}>
<td className="pr-6 py-0.5 font-medium text-gray-900">{plan.name}</td>
<td className="pr-6 py-0.5 tabular-nums">{limitText(plan.daily)} / {limitText(plan.monthly)}</td>
<td className="py-0.5 tabular-nums">{limitText(plan.tenantDaily)} / {limitText(plan.tenantMonthly)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!telemetry.requestsAvailable ? (
<DataNote>The request telemetry did not answer: the refusals are missing.</DataNote>
) : quotaHits.length === 0 ? (
<p className="px-6 py-8 text-center text-sm text-gray-500">No request was refused by a daily or monthly quota in this window.</p>
) : (
<ul className="divide-y divide-gray-200">
{quotaHits.map((hit) => (
<li
key={`${hit.date}|${hit.tenantId}|${hit.principal ?? ""}|${hit.level}|${hit.scope}`}
className="px-4 sm:px-6 py-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
>
<span className="w-16 shrink-0 text-gray-500 tabular-nums">{formatDayKey(hit.date)}</span>
<span className="min-w-0 basis-full sm:basis-auto sm:flex-1">
<span className="text-gray-900">{hit.principal ? <Principal principal={hit.principal} /> : "—"}</span>
<span className="ml-2 text-xs text-gray-500">{tenantLabel(hit.tenantId, info)}</span>
</span>
<Pill>{hit.level === "tenant" ? "Organization" : "Account"} · {hit.scope}</Pill>
<Pill>plan {hit.plan}</Pill>
<span className="tabular-nums text-red-700" title="The window's count at the first refusal of the day, of its limit">
{hit.used?.toLocaleString() ?? "?"} / {hit.limit?.toLocaleString() ?? "?"}
</span>
<span className="text-xs text-gray-500">
{hit.refused === 1 ? "1 request refused" : `${hit.refused.toLocaleString()} requests refused`}, {timeOfDay(hit.firstAt)}
{hit.lastAt !== hit.firstAt && ` – ${timeOfDay(hit.lastAt)}`}
</span>
</li>
))}
</ul>
)}
</div>
);
}
function MinuteSection({
minuteLimits,
telemetry,
info,
}: {
minuteLimits: McpUsageMinuteLimits;
telemetry: McpUsageTelemetryStatus;
info: ReadonlyMap<string, TenantInfo>;
}) {
const [showAll, setShowAll] = useState(false);
const mcpLimit = minuteLimits.mcpServerLimit;
const close = minuteRowsToShow(minuteLimits.accounts, mcpLimit);
const rows = showAll ? minuteLimits.accounts : close;
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 sm:px-6 py-3 border-b border-gray-200 flex flex-wrap items-center gap-3">
<h3 className="text-sm font-medium text-gray-900">Per-minute limits</h3>
<span className="text-xs text-gray-500">
MCP server: {mcpLimit ? `${mcpLimit} messages per minute per account` : "limit not reported yet"} · Backend: per account, shown in each row
</span>
{minuteLimits.accounts.length > close.length && (
<label className="ml-auto inline-flex items-center gap-1.5 text-xs text-gray-600">
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} className="rounded border-gray-300" />
Show all {minuteLimits.accounts.length} accounts
</label>
)}
</div>
{!telemetry.requestsAvailable && <DataNote>The request telemetry did not answer: the peaks and the backend refusals are missing.</DataNote>}
{!telemetry.mcpLogAvailable && <DataNote>The MCP log did not answer: the MCP server&apos;s refusals and its limit are missing.</DataNote>}
{rows.length === 0 ? (
<p className="px-6 py-8 text-center text-sm text-gray-500">
{minuteLimits.accounts.length === 0
? "No MCP requests in this window."
: "No account came within half of a per-minute limit, and none was refused."}
</p>
) : (
<ul className="divide-y divide-gray-200">
{rows.map((row) => (
<li
key={`${row.tenantId}|${row.principal}`}
className="px-4 sm:px-6 py-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_12rem_12rem_6rem] lg:items-center"
>
<div className="min-w-0 text-sm text-gray-900">
<Principal principal={row.principal} />
<div className="text-xs text-gray-500">
{tenantLabel(row.tenantId, info)} · busiest 60 s at {minuteShare(row, mcpLimit)} % of a limit
</div>
</div>
<div>
{mcpLimit ? (
<QuotaBar label="Tool calls / 60 s" used={row.peakCalls} limit={mcpLimit} />
) : (
<span className="text-xs text-gray-600">Tool calls / 60 s: {row.peakCalls.toLocaleString()}</span>
)}
{row.mcpRefused > 0 && (
<p className="mt-1 text-xs text-red-700">{row.mcpRefused.toLocaleString()} refused by the MCP server, last {timeOfDay(row.mcpLastRefusedAt)}</p>
)}
</div>
<div>
<QuotaBar label="Requests / 60 s" used={row.peakRequests} limit={row.backendLimit} />
{row.backendRefused > 0 && (
<p className="mt-1 text-xs text-red-700">{row.backendRefused.toLocaleString()} refused by the backend, last {timeOfDay(row.backendLastRefusedAt)}</p>
)}
</div>
<span className="text-xs text-gray-500">{timeOfDay(row.peakRequestsAt ?? row.peakCallsAt)}</span>
</li>
))}
</ul>
)}
</div>
);
}
@@ -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<UsageSortKey, SortDir> = { 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<string, TenantInfo> }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const [nearLimitOnly, setNearLimitOnly] = useState(false);
const { sortKey, sortDir, onSort } = useSortState<UsageSortKey>("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 (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 sm:px-6 py-3 border-b border-gray-200 flex flex-wrap items-center gap-3">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Tenant, user or app id"
ariaLabel="Search tenants, users and apps"
name="mcp-usage-search"
tone="slate"
/>
<label className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<input type="checkbox" checked={nearLimitOnly} onChange={(e) => setNearLimitOnly(e.target.checked)} className="rounded border-gray-300" />
Near a limit
</label>
<span className="text-xs text-gray-500">
{matches.length === tenants.length ? `${tenants.length} tenants` : `${matches.length} of ${tenants.length} tenants`}
</span>
</div>
<div className={`hidden ${COLS} px-4 sm:px-6 py-2 border-b border-gray-200 bg-gray-50`}>
<span className="text-xs font-medium uppercase tracking-wider text-gray-500">Tenant</span>
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right">Accounts</span>
<span className="text-right"><SortHeader sortKey="calls" current={sortKey} dir={sortDir} onSort={onSort} align="right">Calls</SortHeader></span>
<span className="text-right"><SortHeader sortKey="requests" current={sortKey} dir={sortDir} onSort={onSort} align="right">Requests</SortHeader></span>
<SortHeader sortKey="today" current={sortKey} dir={sortDir} onSort={onSort}>Today</SortHeader>
<SortHeader sortKey="month" current={sortKey} dir={sortDir} onSort={onSort}>Month</SortHeader>
<SortHeader sortKey="lastActive" current={sortKey} dir={sortDir} onSort={onSort}>Last active</SortHeader>
</div>
{matches.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-gray-500">
{tenants.length === 0
? "No MCP use in this window."
: searching
? `No tenant, user or app matches “${deferredQuery.trim()}”.`
: "No tenant is near a limit."}
</p>
) : (
<ul className="divide-y divide-gray-200">
{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 (
<li key={tenant.tenantId}>
<button
type="button"
onClick={() => 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}`}
>
<div className="flex min-w-0 basis-full items-start gap-2 lg:basis-auto">
<span className="mt-0.5"><Chevron open={isOpen} /></span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-gray-900 break-all sm:break-normal sm:truncate">
{t?.domainName ?? tenant.tenantId}
</span>
{t && <EditionBadge edition={t.edition} />}
{!isDefaultPlan(tenant.quota.tenantPlan, t) && <Pill title="Usage plan set for this tenant">plan {tenant.quota.tenantPlan}</Pill>}
{t?.mcpDisabled && <Pill title="MCP is closed for this tenant">MCP closed</Pill>}
<HitsPill count={tenant.quotaHits} title="Quota refusals in this window" />
</div>
{t?.companyName && <div className="text-xs text-gray-500 truncate">{t.companyName}</div>}
</div>
</div>
<Figure label="Accounts" value={apps > 0 ? `${tenant.accounts.length - apps} + ${apps} app${apps === 1 ? "" : "s"}` : String(tenant.accounts.length)} />
<Figure label="Calls" value={tenant.calls.toLocaleString()} />
<Figure label="Requests" value={tenant.requests.toLocaleString()} />
<div className="w-full sm:w-40 lg:w-auto">
<QuotaBar label="Today" used={tenant.quota.dailyUsed} limit={tenant.quota.dailyLimit} />
</div>
<div className="w-full sm:w-40 lg:w-auto">
<QuotaBar label="Month" used={tenant.quota.monthlyUsed} limit={tenant.quota.monthlyLimit} />
</div>
<span className="text-xs text-gray-500"><span className="lg:hidden">Last active </span>{formatLastActive(tenant.lastRequestAt)}</span>
</button>
{isOpen && (
<AccountList accounts={sortAccounts(accounts, sortKey, sortDir)} tenantPlan={tenant.quota.tenantPlan} tenantId={tenant.tenantId} />
)}
</li>
);
})}
</ul>
)}
</div>
);
}
function AccountList({ accounts, tenantPlan, tenantId }: { accounts: readonly McpUsageAccountItem[]; tenantPlan: string; tenantId: string }) {
return (
<div className="bg-gray-50 border-t border-gray-200">
<ul className="divide-y divide-gray-200">
{accounts.map((account) => (
<AccountRow key={account.principal} account={account} tenantPlan={tenantPlan} />
))}
</ul>
<div className="px-4 sm:px-6 py-2 text-right">
<Link href={tenantManagementUrl(tenantId)} className="text-xs text-blue-700 hover:underline">
Open in Tenant Management
</Link>
</div>
</div>
);
}
function AccountRow({ account, tenantPlan }: { account: McpUsageAccountItem; tenantPlan: string }) {
const appId = applicationIdOf(account.principal);
return (
<li className={`px-4 sm:px-6 py-3 pl-10 sm:pl-12 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}>
<div className="min-w-0 basis-full lg:basis-auto">
<div className="flex flex-wrap items-center gap-1.5">
{appId && <Pill title="Service principal: an application calling with an app-only token">App</Pill>}
<span className={`text-sm text-gray-900 break-all sm:break-normal sm:truncate ${appId ? "font-mono" : ""}`}>
{appId ?? account.principal}
</span>
{account.role && <Pill title="Role on the latest request">{account.role}</Pill>}
{account.isPlatformAdmin && <Pill title="Global Admin or Global Reader">platform</Pill>}
{account.quota && account.quota.plan !== tenantPlan && <Pill title="Personal usage plan">plan {account.quota.plan}</Pill>}
<HitsPill count={account.quotaHits} title="Days this account met its own quota" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{account.topTools.map((t) => (
<span key={t.tool} className="rounded bg-white border border-gray-200 px-1.5 py-0.5 text-xs font-mono text-gray-600" title={`${t.calls} calls`}>
{t.tool} <span className="text-gray-400">{t.calls}</span>
</span>
))}
{account.peakDay && (
<span className="text-xs text-gray-500">
busiest day {formatDayKey(account.peakDay)} with {account.peakDayRequests?.toLocaleString()} requests
</span>
)}
</div>
</div>
<span className="hidden lg:block" />
<Figure label="Calls" value={account.calls.toLocaleString()} />
<Figure label="Requests" value={account.requests.toLocaleString()} />
<div className="w-full sm:w-40 lg:w-auto">
{account.quota ? <QuotaBar label="Today" used={account.quota.dailyUsed} limit={account.quota.dailyLimit} /> : <span className="text-xs text-gray-400">no quota state</span>}
</div>
<div className="w-full sm:w-40 lg:w-auto">
{account.quota && <QuotaBar label="Month" used={account.quota.monthlyUsed} limit={account.quota.monthlyLimit} />}
</div>
<span className="text-xs text-gray-500"><span className="lg:hidden">Last active </span>{formatLastActive(account.lastRequestAt)}</span>
</li>
);
}
@@ -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<ToolSortKey, SortDir> = { 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<ToolSortKey>("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 (
<div className="bg-white rounded-lg shadow overflow-hidden">
{!telemetry.requestsAvailable && (
<DataNote>The request telemetry did not answer: calls, reach and the cost per call are missing.</DataNote>
)}
{!telemetry.mcpLogAvailable && (
<DataNote>The MCP log did not answer: error rate, duration, answer size and the MCP-only tools are missing.</DataNote>
)}
{telemetry.mcpLogFrom && (
<DataNote>
Error rate, duration, answer size and the MCP-only tools cover {formatDayKey(telemetry.mcpLogFrom)} – {formatDayKey(dateTo)}.
</DataNote>
)}
<div className={`hidden ${COLS} px-4 sm:px-6 py-2 border-b border-gray-200 bg-gray-50`}>
<span className="text-xs font-medium uppercase tracking-wider text-gray-500">Tool</span>
<SortHeader sortKey="calls" current={sortKey} dir={sortDir} onSort={onSort}>Calls</SortHeader>
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right">Accounts</span>
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right">Tenants</span>
<span className="text-right" title="Backend requests one call costs the quota on average">
<SortHeader sortKey="requestsPerCall" current={sortKey} dir={sortDir} onSort={onSort} align="right">Req./call</SortHeader>
</span>
<span className="text-right" title="Calls that ended as an error, soft errors included">
<SortHeader sortKey="errorRate" current={sortKey} dir={sortDir} onSort={onSort} align="right">Errors</SortHeader>
</span>
<span className="text-right" title="95 % of the calls took at most this long">
<SortHeader sortKey="p95" current={sortKey} dir={sortDir} onSort={onSort} align="right">p95</SortHeader>
</span>
<span className="text-right" title="Mean size of the answer the model reads">
<SortHeader sortKey="size" current={sortKey} dir={sortDir} onSort={onSort} align="right">Size</SortHeader>
</span>
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 text-right" title="Calls whose page exceeded the tool's size cap">
Over cap
</span>
</div>
{sorted.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-gray-500">No tool calls in this window.</p>
) : (
<ul className="divide-y divide-gray-200">
{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 = (
<>
<div className="flex min-w-0 basis-full items-center gap-2 lg:basis-auto">
{expandable ? <Chevron open={isOpen} /> : <span className="w-4 shrink-0" />}
<span className="font-mono text-sm text-gray-900 break-all sm:break-normal sm:truncate">{tool.tool}</span>
{mcpOnly && <Pill title="Runs in the MCP server alone: no backend request, no quota cost">MCP only</Pill>}
</div>
<div className="flex w-full items-center gap-2 sm:w-48 lg:w-auto">
<div className="h-2 flex-1 rounded-full bg-gray-100">
<div className="h-2 rounded-full bg-indigo-500" style={{ width: `${share}%` }} />
</div>
<span className="w-12 text-right text-sm tabular-nums text-gray-900">{tool.calls.toLocaleString()}</span>
</div>
<Figure label="Accounts" value={tool.accounts?.toLocaleString() ?? "—"} />
<Figure label="Tenants" value={tool.tenants?.toLocaleString() ?? "—"} />
<Figure label="Req./call" value={mcpOnly ? "0" : tool.requestsPerCall.toLocaleString(undefined, { maximumFractionDigits: 1 })} />
<Figure label="Errors" value={formatShare(tool.errorRate)} tone={tool.errorRate != null && tool.errorRate >= 0.05 ? "warn" : undefined} />
<Figure label="p95" value={formatDuration(tool.p95DurationMs)} />
<Figure label="Size" value={formatChars(tool.avgResultChars)} />
<Figure label="Over cap" value={tool.overCapCalls?.toLocaleString() ?? "—"} tone={tool.overCapCalls ? "warn" : undefined} />
</>
);
return (
<li key={tool.tool}>
{expandable ? (
<button
type="button"
onClick={() => 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}
</button>
) : (
<div className={`px-4 sm:px-6 py-3 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}>{row}</div>
)}
{isOpen && (
<div className="bg-gray-50 border-t border-gray-200 px-4 sm:px-6 py-2 pl-10 sm:pl-12">
<p className="text-xs text-gray-500 mb-1">Backend requests of its calls</p>
<ul className="space-y-0.5">
{tool.routes.map((route) => (
<li key={route.route} className="flex items-center justify-between gap-3 text-xs">
<span className="font-mono text-gray-700 break-all">{route.route}</span>
<span className="tabular-nums text-gray-600">{route.requests.toLocaleString()}</span>
</li>
))}
</ul>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -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<string, TenantInfo> {
const map = new Map<string, TenantInfo>();
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, TenantInfo>): 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:<client-id>) matches. Case-insensitive substring; an empty query keeps everything.
*/
export function searchTenants(
tenants: readonly McpUsageTenantItem[],
info: ReadonlyMap<string, TenantInfo>,
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<string, TenantInfo>): 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<Record<string, string | number>>;
/** 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<string, number>();
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<string, string | number> = { 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)} %`;
}
@@ -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 (
<svg
className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
/** A column label that sorts: ▲/▼ on the active column, ↕ on the others. */
export function SortHeader<K extends string>({
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 (
<button
type="button"
onClick={() => 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}
<span className={`text-[10px] ${active ? "text-indigo-600" : "text-gray-300"}`} aria-hidden="true">
{active ? (dir === "asc" ? "▲" : "▼") : "↕"}
</span>
</button>
);
}
/** A neutral context label (role, plan, origin) in the gray-outline pill style. */
export function Pill({ children, title }: { children: ReactNode; title?: string }) {
return (
<span title={title} className="inline-flex shrink-0 items-center rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
{children}
</span>
);
}
/** The tenant's edition in the colors the tenant lists use. */
export function EditionBadge({ edition }: { edition: PlanEdition }) {
return (
<span className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${facetOption("plan", edition)?.badgeClass ?? ""}`}>
{editionLabel(edition)}
</span>
);
}
/** 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 (
<span title={title} className="inline-flex shrink-0 items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
{count === 1 ? "1 limit hit" : `${count} limit hits`}
</span>
);
}
/** 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 (
<span className={`text-sm tabular-nums lg:text-right ${tone === "warn" ? "text-amber-700" : "text-gray-900"}`}>
<span className="text-xs text-gray-500 lg:hidden">{label} </span>
{value}
</span>
);
}
/** 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 <p className="px-4 sm:px-6 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">{children}</p>;
}
@@ -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<K extends string>(initial: K, defaultDir: Record<K, SortDir>) {
const [sortKey, setSortKey] = useState<K>(initial);
const [sortDir, setSortDir] = useState<SortDir>(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<ReadonlySet<string>>(() => 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 };
}
@@ -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 (
<div>
<div className="flex items-start justify-between text-xs text-gray-600 mb-1">
<span>{label}</span>
<span className="font-mono text-right">
{unavailable ? "n/a" : used.toLocaleString()}
{" / "}
{unlimited ? "unlimited" : limit.toLocaleString()}
{breakdown && (
<span className="block text-gray-400" title="Plan base + purchased delegation slots × growth per slot">
{breakdown}
</span>
)}
</span>
</div>
<div className="bg-gray-100 rounded-full h-2">
<div className={`${tone} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
function formatDate(yyyymmdd: string): string {
if (yyyymmdd.length !== 8) return yyyymmdd;
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
@@ -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 (
<div>
<div className="flex items-start justify-between text-xs text-gray-600 mb-1">
<span>{label}</span>
<span className="font-mono text-right">
{unavailable ? "n/a" : used.toLocaleString()}
{" / "}
{unlimited ? "unlimited" : limit.toLocaleString()}
{breakdown && (
<span className="block text-gray-400" title="Plan base + purchased delegation slots × growth per slot">
{breakdown}
</span>
)}
</span>
</div>
<div className="bg-gray-100 rounded-full h-2">
<div className={`${tone} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import {
Cell,
} from "recharts";
import { chartColors, tooltipStyle, axisTick, axisLine } from "./chartTheme";
import { RenderedTooltip } from "./RenderedTooltip";
export interface AppBarChartProps {
data: Array<Record<string, unknown>>;
@@ -33,20 +34,6 @@ export interface AppBarChartProps {
integerTicks?: boolean;
}
interface RenderedTooltipProps {
render: (row: Record<string, unknown>) => 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 <div style={tooltipStyle}>{render(row as Record<string, unknown>)}</div>;
}
/**
* 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.
@@ -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<string, unknown>) => ReactNode;
}
/**
@@ -39,6 +43,7 @@ export default function AppStackedBarChart({
stacks,
height = 260,
formatXTick,
renderTooltip,
}: AppStackedBarChartProps) {
return (
<ResponsiveContainer width="100%" height={height}>
@@ -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: <RenderedTooltip render={renderTooltip} /> } : {})}
/>
<Legend wrapperStyle={legendStyle} iconType="square" />
{stacks.map((s) => (
@@ -0,0 +1,21 @@
"use client";
import type { ReactNode } from "react";
import { tooltipStyle } from "./chartTheme";
interface RenderedTooltipProps {
render: (row: Record<string, unknown>) => 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 <div style={tooltipStyle}>{render(row as Record<string, unknown>)}</div>;
}
+3 -4
View File
@@ -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`,
},
-1
View File
@@ -50,7 +50,6 @@
"a3b1b3898c247667": 1,
"a777f9e57bac53d5": 1,
"a8ea1228aa75b506": 1,
"ab6861a9ea29854d": 1,
"af270234f673d876": 1,
"b26235044348e7ba": 1,
"b62f5f7b96567483": 1,