Files
AutopilotMonitor/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs
T
Oliver Kieselbach 71282f3556 feat(backend,web,mcp): the MCP quota follows the delegating tenant — every request is checked and counted on the caller's home tenant, a managed tenant is never charged or blocked by its manager's reads, and every delegation slot bought beyond the edition's included ones grows both the account and the organization windows by operator-editable per-slot values (D-250)
Backend/Shared:
- McpQuotaService: ResolveHomeAsync reads definitions, entitlements, plan name and purchased slots once per check; Grow adds slots × per-slot value to every window (a lifted window stays unlimited); the decision and McpPlanLimits carry the breakdown; CheckManyAsync, the charge scope and TargetTenantId are gone
- McpQuotaEnforcementMiddleware: one home charge for every caller; IsExempt = Global Admin only (counted, never blocked; the Global Reader stays quota'd); two 429 texts (organization / user)
- TenantEntitlementService.GetPurchasedDelegatedSlots = max(0, effective slot limit − catalog count), 0 without the delegation right; FeatureEntitlementCatalog gains McpSlot*RequestLimit (Pro 300/6000 per account, 900/18000 per organization = 30 % of the Pro windows; Community and conferred Pro 0)
- PlanTierDefinition gains four nullable slot fields; GET/PUT global/config/plan-tiers return the catalog (UsagePlanCatalogDefaults per edition) so a blank field is explained
- McpUsageQuotaNode / McpOrganizationQuotaNode carry purchasedDelegatedSlots and the slot values (absent without purchased slots)
- Removed: the Tenant Group flag ChargeHomeTenantQuota with its scope plumbing (HomeChargedTenantIds, IsDelegatedAggregate, CatalogDecisionResult), Narrow/quotaExcludedTenants on the session envelopes and the X-MCP-Quota-Excluded-Tenants header, targetTenantId on the 429 body, the foreign-home attribution on McpTenantUsage (HomeTenantId, delegated/homeTenantId on the organization report) and the managed tenant's budget on the manager's managed-tenant list; PATCH global/tenant-groups/{id} is a rename

Web: SectionUsagePlans with four slot inputs, catalog placeholders and a built-in-defaults line; SectionMcpUsage renders "base + slots × per-slot" on both cards and drops the delegated badge; SectionTenantGroups without the charge toggle; SectionDelegatedAccess, the accept page and the privacy page state the new model (privacy last updated 2026-09-11).
MCP: get_fleet_overview no longer merges an exclusion list, the 429 renderer names only the caller's organization, the delegated-scope instruction states the home-charge model; wire types regenerated.
Tests: quota service slot-growth suite, entitlement purchased-slot matrix, conferred-Pro self-service writes (403/409), parity facts for the new and the removed fields; the radar analytics fixture is dated off the real clock (it had aged out of the 30-day window).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 16:46:27 +02:00

279 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Shared.Models
{
// -------------------------------------------------------------------------------------
// Typed wire DTOs for the Metrics function folder (anonymous-object → typed migration).
// Envelope classes implement IApiResponse; nested item classes stay flat and carry no
// marker interface. Property declaration order IS the JSON key order.
// DTOs of the two GeographicLocationSessions endpoints live in GeographicApiModels.cs.
// -------------------------------------------------------------------------------------
/// <summary>
/// Device enrollment-history envelope (GetDeviceHistory). <see cref="History"/> is absent
/// as a NORMAL outcome (unknown device, junk serial, or every chain ref pruned).
/// </summary>
// Declaration order == wire order.
public class GetDeviceHistoryResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Absent when the device has no history row — the banner simply stays hidden.</summary>
public DeviceHistory? History { get; set; }
/// <summary>
/// The requesting session's attempt number within its journey; absent without a
/// <c>?sessionId=</c> parameter, when the session is unknown, or when the position
/// cannot be computed (fail-soft, never a guessed position).
/// </summary>
public int? AttemptNumber { get; set; }
}
/// <summary>
/// Landing-page platform stats envelope (GetPlatformStats, unauthenticated). When no
/// stats row has been computed yet, the zero shape is served: all counters 0 with
/// <see cref="TotalSignedUpTenants"/> and <see cref="LastUpdated"/> absent.
/// </summary>
// Declaration order == wire order.
public class GetPlatformStatsResponse : IApiResponse
{
public long TotalEnrollments { get; set; }
public long TotalUsers { get; set; }
public long TotalTenants { get; set; }
/// <summary>Absent on the not-yet-computed zero shape.</summary>
public long? TotalSignedUpTenants { get; set; }
public long UniqueDeviceModels { get; set; }
public long TotalEventsProcessed { get; set; }
public long SuccessfulEnrollments { get; set; }
public long IssuesDetected { get; set; }
/// <summary>Always absent today: the computed shape never sets it, the zero shape sets null.</summary>
public DateTime? LastUpdated { get; set; }
}
/// <summary>
/// Per-session time-attribution envelope (GetSessionTimeAttribution). A missing breakdown
/// is a NORMAL outcome (pre-feature session, non-terminal, Incomplete — no wall clock).
/// </summary>
// Declaration order == wire order.
public class GetSessionTimeAttributionResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Absent when no breakdown row exists — the UI simply omits the lane.</summary>
public SessionTimeBreakdown? Breakdown { get; set; }
}
/// <summary>
/// Self-service MCP/API usage envelope (GetMyMcpUsage): the caller's own usage records
/// plus resolved plan and quota state. The MCP server paginates over the
/// <c>records</c> key — its name is wire-critical.
/// </summary>
// Declaration order == wire order.
public class GetMyMcpUsageResponse : IApiResponse
{
public string UserId { get; set; } = default!;
/// <summary>Absent when the token carries no UPN claim.</summary>
public string? Upn { get; set; }
/// <summary>Per-user plan override from the caller's own whitelist row; absent without one.</summary>
public string? UsagePlan { get; set; }
/// <summary>Resolved effective plan (per-user override → tenant edition).</summary>
public string EffectivePlan { get; set; } = default!;
public McpUsageQuotaNode Quota { get; set; } = default!;
public IReadOnlyList<UserUsageRecord> Records { get; set; } = default!;
}
/// <summary>
/// Effective quota state nested in <see cref="GetMyMcpUsageResponse"/>: the caller's own windows and
/// the organization-wide windows of their HOME tenant (shared by every member; 0 = unlimited). Every
/// MCP request the caller makes — reads into managed tenants included — draws on these windows. The
/// limits already contain the growth from purchased delegation slots; the slot fields let a client show
/// the breakdown (base = limit − slots × slot value) and are absent when the tenant has no purchased slot.
/// </summary>
// Declaration order == wire order.
public class McpUsageQuotaNode
{
public int DailyLimit { get; set; }
public int MonthlyLimit { get; set; }
public long DailyUsed { get; set; }
public long MonthlyUsed { get; set; }
public DateTime ResetUtc { get; set; }
public string TenantPlan { get; set; } = default!;
public int TenantDailyLimit { get; set; }
public int TenantMonthlyLimit { get; set; }
public long TenantDailyUsed { get; set; }
public long TenantMonthlyUsed { get; set; }
/// <summary>Delegation slots bought beyond the edition's included ones; absent when zero.</summary>
public int? PurchasedDelegatedSlots { get; set; }
/// <summary>Per-user daily growth per purchased slot; absent when no slot was purchased.</summary>
public int? SlotDailyLimit { get; set; }
public int? SlotMonthlyLimit { get; set; }
/// <summary>Organization daily growth per purchased slot; absent when no slot was purchased.</summary>
public int? SlotTenantDailyLimit { get; set; }
public int? SlotTenantMonthlyLimit { get; set; }
}
/// <summary>
/// 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 <c>records</c>). The MCP server paginates over
/// the <c>records</c> key — its name is wire-critical.
/// </summary>
// Declaration order == wire order.
public class GetMcpUserUsageResponse : IApiResponse
{
public string UserId { get; set; } = default!;
public IReadOnlyList<UserUsageRecord> Records { get; set; } = default!;
}
/// <summary>
/// Organization-wide MCP usage by user for ONE tenant — the caller's own (GetMcpOrganizationUsage) or the
/// tenant a Global Admin / Global Reader names (GetGlobalMcpOrganizationUsage): every account of the
/// tenant whose requests were charged to its organization budget (a member's reads into managed tenants
/// included), plus the tenant's organization windows. Built from the tenant's organization counters.
/// </summary>
// Declaration order == wire order.
public class GetMcpOrganizationUsageResponse : IApiResponse
{
public string TenantId { get; set; } = default!;
/// <summary>Effective range start (yyyyMMdd).</summary>
public string DateFrom { get; set; } = default!;
/// <summary>Effective range end (yyyyMMdd, inclusive).</summary>
public string DateTo { get; set; } = default!;
public IReadOnlyList<McpOrganizationUsageItem> Users { get; set; } = default!;
/// <summary>The tenant's organization windows (plan, limits, today / this month) from the same counters.</summary>
public McpOrganizationQuotaNode Quota { get; set; } = default!;
/// <summary>Requests charged to the tenant per day inside the range, oldest first; days without requests are absent.</summary>
public IReadOnlyList<McpOrganizationDailyItem> Daily { get; set; } = default!;
}
/// <summary>One day of the tenant's organization budget, nested in <see cref="GetMcpOrganizationUsageResponse"/>.</summary>
// Declaration order == wire order.
public class McpOrganizationDailyItem
{
/// <summary>yyyyMMdd.</summary>
public string Date { get; set; } = default!;
public long Requests { get; set; }
}
/// <summary>
/// The organization-wide windows of the tenant in <see cref="GetMcpOrganizationUsageResponse"/>: its tenant
/// plan (tenant-wide override, else edition) with the limits (0 = unlimited; purchased-slot growth included)
/// and the counters over every account charged to the tenant. The slot fields carry the breakdown and are
/// absent when the tenant has no purchased delegation slot.
/// </summary>
// Declaration order == wire order.
public class McpOrganizationQuotaNode
{
public string TenantPlan { get; set; } = default!;
public int DailyLimit { get; set; }
public int MonthlyLimit { get; set; }
public long DailyUsed { get; set; }
public long MonthlyUsed { get; set; }
/// <summary>Delegation slots bought beyond the edition's included ones; absent when zero.</summary>
public int? PurchasedDelegatedSlots { get; set; }
/// <summary>Organization daily growth per purchased slot; absent when no slot was purchased.</summary>
public int? SlotDailyLimit { get; set; }
public int? SlotMonthlyLimit { get; set; }
}
/// <summary>One account's share of the organization budget, nested in <see cref="GetMcpOrganizationUsageResponse"/>.</summary>
// Declaration order == wire order.
public class McpOrganizationUsageItem
{
public string UserId { get; set; } = default!;
/// <summary>Absent for rows written before the UPN was recorded.</summary>
public string? UserPrincipalName { get; set; }
public long RequestsToday { get; set; }
public long RequestsThisMonth { get; set; }
public long RequestsInRange { get; set; }
/// <summary>Absent for rows written before the timestamp was recorded.</summary>
public DateTime? LastRequestAt { get; set; }
}
/// <summary>
/// Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates
/// over the <c>records</c> key — its name is wire-critical.
/// </summary>
// Declaration order == wire order.
public class GetGlobalMcpUsageResponse : IApiResponse
{
/// <summary>Echo of the request filter; absent when the caller passed no tenantId.</summary>
public string? TenantId { get; set; }
public IReadOnlyList<UserUsageRecord> Records { get; set; } = default!;
}
/// <summary>
/// Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server
/// paginates over the <c>summaries</c> key — its name is wire-critical.
/// </summary>
// Declaration order == wire order.
public class GetGlobalMcpUsageDailyResponse : IApiResponse
{
/// <summary>Echo of the request filter; absent when the caller passed no tenantId.</summary>
public string? TenantId { get; set; }
public IReadOnlyList<UserUsageDailySummary> Summaries { get; set; } = default!;
}
/// <summary>
/// Per-tenant session-status tally envelope shared by MetricsSummary and
/// MetricsSummaryGlobal.
/// </summary>
// Declaration order == wire order.
public class MetricsSummaryResponse : IApiResponse
{
public bool Success { get; set; }
public IReadOnlyList<MetricsSummaryTenantItem> Summary { get; set; } = default!;
public int WindowDays { get; set; }
}
/// <summary>
/// One per-tenant status tally in <see cref="MetricsSummaryResponse"/>. WindowDays is
/// repeated per item (envelope carries it too — historical wire shape, kept for parity).
/// </summary>
// Declaration order == wire order.
public class MetricsSummaryTenantItem
{
public string TenantId { get; set; } = default!;
public int TotalSessions { get; set; }
public int Succeeded { get; set; }
public int Failed { get; set; }
public int InProgress { get; set; }
public int Pending { get; set; }
public int Stalled { get; set; }
public int AwaitingUser { get; set; }
public int Incomplete { get; set; }
public int Other { get; set; }
/// <summary>Failed over the TERMINAL outcomes only (Succeeded + Failed), percent rounded to one decimal; 0 without terminal sessions.</summary>
public double FailureRate { get; set; }
public int WindowDays { get; set; }
}
/// <summary>
/// Session ids where a rule produced a result within the window (GetRuleHitSessions) —
/// powers the dashboard's <c>?ruleId=</c> deep link.
/// </summary>
// Declaration order == wire order.
public class GetRuleHitSessionsResponse : IApiResponse
{
public string RuleId { get; set; } = default!;
public int Days { get; set; }
public IReadOnlyList<string> SessionIds { get; set; } = default!;
/// <summary>True when the result hit the hit-set cap (2000) — the list is a lower bound.</summary>
public bool Truncated { get; set; }
}
}