diff --git a/internal b/internal index 94e48036..fe1f232a 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 94e4803674730350c944e2024593dea117ee247c +Subproject commit fe1f232addff9c8cfe5fdea9c3f215bdb358f1d0 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpOrganizationUsageAggregationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpOrganizationUsageAggregationTests.cs index f6fdc234..b48f41d2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpOrganizationUsageAggregationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpOrganizationUsageAggregationTests.cs @@ -110,6 +110,23 @@ public class McpOrganizationUsageAggregationTests Assert.Equal(27, quota.MonthlyUsed); } + [Fact] + public void OrganizationDaily_SumsEveryAccountPerDay_InsideTheRange_OldestFirst() + { + var rows = new[] + { + Row(Member, "20260815", 100), // before the range + Row(Member, "20260902", 2), + Row(MspAdmin, "20260902", 5, home: MspHome), // same day, delegated — one bar + Row(Member, "20260901", 20), + Row(Member, "20260903", 7), // after the range (read window) + }; + + var daily = McpUsageMetricsFunction.BuildOrganizationDaily(rows, "20260901", "20260902"); + + Assert.Equal(new[] { ("20260901", 20L), ("20260902", 7L) }, daily.Select(d => (d.Date, d.Requests))); + } + [Fact] public void OrganizationQuota_LiftedWindows_StillReportTheCounters() { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs index 0bd79152..5224da63 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs @@ -160,6 +160,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics DateTo = dateTo, Users = AggregateOrganizationUsage(records, tenantId, dateFrom, dateTo, today, monthStart), Quota = BuildOrganizationQuota(records, limits, today, monthStart), + Daily = BuildOrganizationDaily(records, dateFrom, dateTo), }); return response; } @@ -169,6 +170,21 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } } + /// Pure: requests charged to the tenant per day inside [dateFrom, dateTo], oldest first. + internal static List BuildOrganizationDaily( + IEnumerable records, string dateFrom, string dateTo) + { + var byDay = new SortedDictionary(StringComparer.Ordinal); + foreach (var record in records) + { + if (string.CompareOrdinal(record.Date, dateFrom) < 0 || string.CompareOrdinal(record.Date, dateTo) > 0) + continue; + byDay[record.Date] = byDay.TryGetValue(record.Date, out var sum) ? sum + record.RequestCount : record.RequestCount; + } + + return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList(); + } + /// /// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over /// every account charged to the tenant, against the tenant plan's limits (0 = unlimited). diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 653f5ae4..a49bf1d3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -2023,6 +2023,8 @@ export interface GetMcpOrganizationUsageResponse { users: McpOrganizationUsageItem[]; /** The tenant's organization windows (plan, limits, today / this month) from the same counters. */ quota: McpOrganizationQuotaNode; + /** Requests charged to the tenant per day inside the range, oldest first; days without requests are absent. */ + daily: McpOrganizationDailyItem[]; } /** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */ @@ -2824,6 +2826,13 @@ export interface McpHealthCheckResponse { check: HealthCheck; } +/** One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse. */ +export interface McpOrganizationDailyItem { + /** yyyyMMdd. */ + date: string; + requests: number; +} + /** The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant. */ export interface McpOrganizationQuotaNode { tenantPlan: string; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs index 7a54a430..1e78bc8c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs @@ -143,6 +143,17 @@ namespace AutopilotMonitor.Shared.Models public IReadOnlyList Users { get; set; } = default!; /// The tenant's organization windows (plan, limits, today / this month) from the same counters. public McpOrganizationQuotaNode Quota { get; set; } = default!; + /// Requests charged to the tenant per day inside the range, oldest first; days without requests are absent. + public IReadOnlyList Daily { get; set; } = default!; + } + + /// One day of the tenant's organization budget, nested in . + // Declaration order == wire order. + public class McpOrganizationDailyItem + { + /// yyyyMMdd. + public string Date { get; set; } = default!; + public long Requests { get; set; } } /// 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 8154d4d0..6b5e2c90 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 @@ -27,10 +27,10 @@ interface UsageRecord { lastRequestAt: string; } +/** One bar of the daily chart — the caller's own requests, or the selected tenant's charged requests. */ interface DailyAggregate { date: string; totalRequests: number; - endpoints: number; } // GetMyMcpUsageResponse.quota — the caller's own windows plus the organization-wide windows every @@ -83,20 +83,33 @@ function getDateTo(): string { return new Date().toISOString().slice(0, 10).replace(/-/g, ""); } +/** The caller's own per-endpoint records folded to one bar per day, newest first. */ +function aggregateOwnRecords(records: UsageRecord[]): DailyAggregate[] { + const byDate = new Map(); + for (const r of records) byDate.set(r.date, (byDate.get(r.date) ?? 0) + r.requestCount); + return Array.from(byDate.entries()) + .map(([date, totalRequests]) => ({ date, totalRequests })) + .sort((a, b) => b.date.localeCompare(a.date)); +} + export function SectionMcpUsage() { const { getAccessToken, user } = useAuth(); const canSeeOrganization = !!(user?.isTenantAdmin || user?.isGlobalAdmin); // Global-admin tenant scope from the reporting header (override-only: always a concrete tenant, - // defaulting to the caller's own). Only the organization cards follow the selection — the caller's own - // quota and request history never do. A delegated (MSP) caller keeps the member path: the organization - // route never lists a managed tenant's accounts (the header shows them no selector). + // defaulting to the caller's own). Viewing ANOTHER tenant is a tenant view: nothing of the caller's + // own account is shown — no UPN, no plan badge, no own quota — and the tiles and the daily chart + // come from the tenant's organization counters. On the own tenant the page stays the caller's usage + // page. A delegated (MSP) caller keeps the member path: the organization route never lists a + // managed tenant's accounts (the header shows them no selector). const scope = useReportingScope(); const crossTenant = scope.routeGlobal && !scope.isDelegatedScope; - const { effectiveTenantId, isGlobalOverride } = scope; + const { effectiveTenantId, isGlobalOverride: tenantView } = scope; + const tenantLabel = scope.tenants.find((t) => t.tenantId === effectiveTenantId)?.domainName ?? effectiveTenantId; const [records, setRecords] = useState([]); - // Organization budget by account + the tenant's windows — tenant admins / GA only; null = not loaded / not permitted. + // Organization budget by account, the tenant's windows and its daily series — tenant admins / GA only; + // null = not loaded / not permitted. const [orgUsage, setOrgUsage] = useState(null); const [usagePlan, setUsagePlan] = useState(null); const [effectivePlan, setEffectivePlan] = useState(null); @@ -121,8 +134,11 @@ export function SectionMcpUsage() { const dateFrom = getDateFrom(range); const dateTo = getDateTo(); const orgSelection = { routeGlobal: crossTenant, selectedTenantId: effectiveTenantId, effectiveTenantId }; - const [data, org] = await Promise.all([ - fetchJson(api.mcpUsage.me(dateFrom, dateTo), getAccessToken), + const [own, org] = await Promise.all([ + // The caller's own usage has no place in another tenant's view. + tenantView + ? Promise.resolve(null) + : fetchJson(api.mcpUsage.me(dateFrom, dateTo), getAccessToken), canSeeOrganization ? // Every account charged to the tenant's organization budget — including delegated (MSP) // administrators reading it. A 403 (role changed mid-session) just hides the card. @@ -136,11 +152,11 @@ export function SectionMcpUsage() { : Promise.resolve(null), ]); if (!isCurrent()) return; - setRecords(data.records || []); - setUsagePlan(data.usagePlan || null); - setEffectivePlan(data.effectivePlan || null); - setQuota(data.quota ?? null); - setUpn(data.upn || ""); + setRecords(own?.records ?? []); + setUsagePlan(own?.usagePlan || null); + setEffectivePlan(own?.effectivePlan || null); + setQuota(own?.quota ?? null); + setUpn(own?.upn || ""); setOrgUsage(org); } catch (err) { if (!isCurrent()) return; @@ -148,7 +164,7 @@ export function SectionMcpUsage() { } finally { if (isCurrent()) setLoading(false); } - }, [getAccessToken, canSeeOrganization, crossTenant, effectiveTenantId]); + }, [getAccessToken, canSeeOrganization, crossTenant, effectiveTenantId, tenantView]); useEffect(() => { const run = async () => { @@ -176,26 +192,14 @@ export function SectionMcpUsage() { : null); // Wording follows the viewpoint: the caller's own tenant, or the tenant a global admin picked. - const tenantNoun = isGlobalOverride ? "this tenant" : "your tenant"; - const budgetNoun = isGlobalOverride ? "this tenant's organization budget" : "your organization budget"; - const planNoun = isGlobalOverride ? "its plan" : "your plan"; + const tenantNoun = tenantView ? "this tenant" : "your tenant"; + const budgetNoun = tenantView ? "this tenant's organization budget" : "your organization budget"; + const planNoun = tenantView ? "its plan" : "your plan"; - // Aggregate records by date - const dailyAggregates: DailyAggregate[] = (() => { - const byDate = new Map }>(); - for (const r of records) { - const existing = byDate.get(r.date); - if (existing) { - existing.total += r.requestCount; - existing.endpoints.add(r.endpoint); - } else { - byDate.set(r.date, { total: r.requestCount, endpoints: new Set([r.endpoint]) }); - } - } - return Array.from(byDate.entries()) - .map(([date, v]) => ({ date, totalRequests: v.total, endpoints: v.endpoints.size })) - .sort((a, b) => b.date.localeCompare(a.date)); - })(); + // Tiles + chart: the tenant's charged requests per day in the tenant view, else the caller's own. + const dailyAggregates: DailyAggregate[] = tenantView + ? (orgUsage?.daily ?? []).map((d) => ({ date: d.date, totalRequests: d.requests })).reverse() + : aggregateOwnRecords(records); const totalRequests = dailyAggregates.reduce((sum, d) => sum + d.totalRequests, 0); const todayStr = getDateTo(); @@ -208,16 +212,18 @@ export function SectionMcpUsage() {

MCP Usage

- {upn &&

{upn}

} + {tenantView + ?

Tenant: {tenantLabel}

+ : upn &&

{upn}

}
- {/* Plan Badge */} - {usagePlan && ( + {/* Plan Badge — the caller's own plan, absent in another tenant's view */} + {!tenantView && usagePlan && ( Plan: {usagePlan} )} - {!usagePlan && ( + {!tenantView && !usagePlan && ( Plan: {effectivePlan ? `${effectivePlan} (inherited)` : "inherited"} @@ -385,7 +391,7 @@ export function SectionMcpUsage() { )} {/* Empty State */} - {!loading && records.length === 0 && !error && ( + {!loading && dailyAggregates.length === 0 && !error && (

No usage data found for the selected period.

diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 4f49a8c1..d3cb1496 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -9587,6 +9587,17 @@ "name": "McpOrganizationQuotaNode" }, "doc": "The tenant's organization windows (plan, limits, today / this month) from the same counters." + }, + { + "name": "daily", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "McpOrganizationDailyItem" + } + }, + "doc": "Requests charged to the tenant per day inside the range, oldest first; days without requests are absent." } ] }, @@ -13334,6 +13345,27 @@ } ] }, + "McpOrganizationDailyItem": { + "kind": "object", + "doc": "One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse.", + "fields": [ + { + "name": "date", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "yyyyMMdd." + }, + { + "name": "requests", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "McpOrganizationQuotaNode": { "kind": "object", "doc": "The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant.", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 20b0363c..b6088a93 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -2023,6 +2023,8 @@ export interface GetMcpOrganizationUsageResponse { users: McpOrganizationUsageItem[]; /** The tenant's organization windows (plan, limits, today / this month) from the same counters. */ quota: McpOrganizationQuotaNode; + /** Requests charged to the tenant per day inside the range, oldest first; days without requests are absent. */ + daily: McpOrganizationDailyItem[]; } /** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */ @@ -2824,6 +2826,13 @@ export interface McpHealthCheckResponse { check: HealthCheck; } +/** One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse. */ +export interface McpOrganizationDailyItem { + /** yyyyMMdd. */ + date: string; + requests: number; +} + /** The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant. */ export interface McpOrganizationQuotaNode { tenantPlan: string;