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"} +

- + +
{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`} + /> + + + + +
- {/* 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 && ( +
+ + + + + + + + + + {plans.map((plan) => ( + + + + + + ))} + +
PlanAccount per day / monthOrganization per day / month
{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 && ( + + )} +
+ {!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 ( +
+
+ + + + {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 ( +
  • + + {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 ? ( + + ) : ( +
      {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 ( + + ); +} + +/** 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,