Compare commits

...

8 Commits

Author SHA1 Message Date
Oliver Kieselbach 225d0feca9 Merge pull request #213 from okieselbach/fix/view-as-bar-layout
View as: keep the navbar and sidebar clear of the view-as bar
2026-11-06 18:47:05 +01:00
Oliver Kieselbach 6b06edb222 View as: keep the navbar and sidebar clear of the view-as bar
The bar sat in the normal flow above the sticky navbar and scrolled away,
while the desktop sidebar is fixed at the navbar's height. In a view-as tab
the navbar therefore covered the top of the sidebar (its first entry) by up
to the bar's height, depending on the scroll position.

The bar now sticks above the navbar at a fixed height: one line, a long
tenant name truncates. globals.css derives --view-as-offset from its
presence (:root:has([data-view-as-bar])): the bar's height while it is
shown, 0 otherwise. Everything positioned against the top of the viewport
adds it: the navbar's sticky top, the desktop sidebar, the mobile
notification panel, the sticky severity card, the push app's header, and
anchor scrolling (scroll-padding-top on the root). The sidebar's section
jump and scroll-spy margin add the bar's measured height
(viewAsBarHeight in lib/viewAs.ts). Ordinary tabs keep offset 0, so their
layout is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:37:49 +01:00
Oliver Kieselbach 12b29c2e08 bump pointer: internal (D-422 tenant activity released and verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:32:11 +01:00
Oliver Kieselbach 5a37721d24 bump pointers: internal, .claude/commands (D-422 tenant activity: plan, decision, docs, health marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:12:29 +01:00
Oliver Kieselbach 884f8495b2 Tenant Activity: per-tenant usage for platform readers, MCP get_tenant_activity (D-422)
New route GET global/metrics/tenant-activity (GlobalReadOrAdmin, no tenant
scope): every signed-up tenant's enrollments, successes and active days over
the last N full UTC days (1-90) against the N days before, a lifecycle stage
(never enrolled, dormant, new, declining, active, quiet), the lifetime
counters, the last counter move and its portal users, plus totals and a daily
series. Built from the daily UsageMetrics rows, the per-tenant counters and
the UserActivity sign-in history; the sign-in read is the only fail-soft input.

- TenantLifecycleFacts: one set of per-tenant facts (enrolled, last active
  day, first day with sessions, the 30-day dormant check) for the growth
  figures and the new route. The window ends at the last aggregated day, so
  shortly after midnight UTC a tenant active only yesterday no longer counts
  as dormant in the growth figures either.
- Shared: GetTenantActivityResponse DTOs, TenantActivityStages and
  TenantEditionNames as manifest vocabularies, PortalVisit; the daily rows
  carry SessionsSucceeded and SessionsFailed. Parity facts, regenerated
  manifest and wire types.
- Web: GA -> Metrics -> Tenant Activity (above MCP Usage) with tiles,
  enrollments per day, a sortable ranking and the lifecycle lists. The
  generic list parts leave the MCP usage folder (lib/useListState, lib/format,
  sections/listParts with TenantNameCell); formatDayKey reads both day-key
  forms and replaces the Customers page's copy; PlanEdition derives from the
  backend's edition names.
- MCP: get_tenant_activity for Global Admins and Global Readers with the views
  summary, ranking and lifecycle; slim rows with fixed caps keep every answer
  below the tool limit. Catalog ceilings raised by the measured size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:12:06 +01:00
Oliver Kieselbach cc674b26ad bump pointer: internal, .claude/commands (D-421 released and verified live; platform-health view-as marker query fixed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:55:39 +01:00
Oliver Kieselbach 7139374681 bump pointer: internal, .claude/commands (D-421 view as tenant: decision, concept doc, plan; marker rows and KQL hints)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:41:24 +01:00
Oliver Kieselbach e6d92c6005 Merge pull request #212 from okieselbach/feat/view-as-tenant
View as tenant: a Global Admin reads one tenant's portal as its member, read-only
2026-11-06 16:38:58 +01:00
60 changed files with 4023 additions and 247 deletions
@@ -235,6 +235,26 @@ public class GrowthMetricsBuilderTests
Assert.Null(a.MedianDaysToFirstEnrollment);
}
[Fact]
public void Before_yesterday_is_aggregated_yesterdays_counters_count_as_activity_and_the_rows_end_the_day_before()
{
// Sessions yesterday after a 40-day pause; the first maintenance run after midnight has not written them yet.
var configs = new[] { Config('a', Utc(6, 1, 9)) };
var stats = new[] { Stats('a', 6, 5, Utc(10, 27, 22)) };
var rows = new[] { Active('a', 2026, 9, 17) };
var early = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions, yesterdayAggregated: false);
Assert.Equal("2026-10-26", early.UsageDaysThrough);
Assert.Equal(0, early.AllTenants.Dormant);
Assert.Equal(1, early.AllTenants.Active7Days);
// Aggregated, yesterday has no row: the counters moved for an old session, which is no activity.
var later = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions);
Assert.Equal("2026-10-27", later.UsageDaysThrough);
Assert.Equal(1, later.AllTenants.Dormant);
}
[Fact]
public void A_peak_needs_three_signups_and_twice_the_median_of_the_two_weeks_before()
{
@@ -1148,6 +1148,177 @@ public class MetricsWireParityTests
AssertParity(GrowthLiteral(null, null), GrowthResponse(null, null));
}
// ---------- GET global/metrics/tenant-activity ----------
private const string ActivityTenantId = "8bb31d22-0004-4c8d-b2e3-63f4bbbb0001";
private static readonly DateTime ActivityOnboardedAt = new(2026, 6, 30, 9, 0, 0, DateTimeKind.Utc);
private static readonly DateTime ActivityCounterAt = new(2026, 11, 6, 7, 30, 0, DateTimeKind.Utc);
private static readonly DateTime ActivityVisitAt = new(2026, 11, 6, 8, 15, 0, DateTimeKind.Utc);
private static GetTenantActivityResponse TenantActivityResponse(bool full) => new()
{
WindowDays = 30,
WindowStart = "2026-10-07",
UsageDaysThrough = "2026-11-05",
PreviousWindowStart = "2026-09-07",
RowsFrom = "2026-05-10",
PortalActivityAvailable = full,
Totals = new TenantActivityTotals
{
SignedUpTenants = 2,
ActiveTenants = 1,
ActiveTenantsPrevious = 1,
ActiveTenantsTrendPercent = full ? 0 : null,
Enrollments = 6,
EnrollmentsPrevious = 4,
EnrollmentsTrendPercent = full ? 50 : null,
Succeeded = 5,
Failed = 1,
SuccessPercent = full ? 83 : null,
Top10SharePercent = full ? 100 : null,
Stages = new TenantActivityStageCounts { NeverEnrolled = 1, Active = 1 },
PortalUsers = full ? 3 : null,
PortalTenants = full ? 1 : null,
},
Daily = new List<TenantActivityDailyItem>
{
new() { Day = "2026-11-05", Enrollments = 6, Succeeded = 5, Failed = 1, ActiveTenants = 1 },
},
Tenants = new List<TenantActivityTenantItem>
{
new()
{
TenantId = ActivityTenantId,
DomainName = "contoso.example",
CompanyName = full ? "Contoso" : null,
Edition = TenantEditionNames.ProTrial,
Stage = TenantActivityStages.Active,
OnboardedAt = full ? ActivityOnboardedAt : null,
Enrollments = 6,
EnrollmentsPrevious = 4,
TrendPercent = full ? 50 : null,
Succeeded = 5,
Failed = 1,
SuccessPercent = full ? 83 : null,
ActiveDays = 1,
DailyEnrollments = full ? new[] { 6 } : null,
FirstEnrollmentDay = full ? "2026-07-01" : null,
LastActiveDay = full ? "2026-11-05" : null,
LastCounterUpdateAt = full ? ActivityCounterAt : null,
LifetimeEnrollments = 120,
LifetimeSucceeded = 100,
LifetimeEvents = 50_000,
PortalUsers = full ? 3 : null,
LastPortalVisitAt = full ? ActivityVisitAt : null,
},
},
};
[Fact]
public void GetTenantActivityResponse_matches_the_shape_with_every_optional_key()
{
AssertParity(new
{
windowDays = 30,
windowStart = "2026-10-07",
usageDaysThrough = "2026-11-05",
previousWindowStart = "2026-09-07",
rowsFrom = "2026-05-10",
portalActivityAvailable = true,
totals = new
{
signedUpTenants = 2,
activeTenants = 1,
activeTenantsPrevious = 1,
activeTenantsTrendPercent = 0,
enrollments = 6,
enrollmentsPrevious = 4,
enrollmentsTrendPercent = 50,
succeeded = 5,
failed = 1,
successPercent = 83,
top10SharePercent = 100,
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
portalUsers = 3,
portalTenants = 1,
},
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
tenants = new[]
{
new
{
tenantId = ActivityTenantId,
domainName = "contoso.example",
companyName = "Contoso",
edition = "pro-trial",
stage = "active",
onboardedAt = ActivityOnboardedAt,
enrollments = 6,
enrollmentsPrevious = 4,
trendPercent = 50,
succeeded = 5,
failed = 1,
successPercent = 83,
activeDays = 1,
dailyEnrollments = new[] { 6 },
firstEnrollmentDay = "2026-07-01",
lastActiveDay = "2026-11-05",
lastCounterUpdateAt = ActivityCounterAt,
lifetimeEnrollments = 120L,
lifetimeSucceeded = 100L,
lifetimeEvents = 50_000L,
portalUsers = 3,
lastPortalVisitAt = ActivityVisitAt,
},
},
}, TenantActivityResponse(full: true));
}
[Fact]
public void GetTenantActivityResponse_omits_every_absent_figure()
{
// No sign-in history, no previous window, no finished session, no names beyond the domain.
AssertParity(new
{
windowDays = 30,
windowStart = "2026-10-07",
usageDaysThrough = "2026-11-05",
previousWindowStart = "2026-09-07",
rowsFrom = "2026-05-10",
portalActivityAvailable = false,
totals = new
{
signedUpTenants = 2,
activeTenants = 1,
activeTenantsPrevious = 1,
enrollments = 6,
enrollmentsPrevious = 4,
succeeded = 5,
failed = 1,
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
},
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
tenants = new[]
{
new
{
tenantId = ActivityTenantId,
domainName = "contoso.example",
edition = "pro-trial",
stage = "active",
enrollments = 6,
enrollmentsPrevious = 4,
succeeded = 5,
failed = 1,
activeDays = 1,
lifetimeEnrollments = 120L,
lifetimeSucceeded = 100L,
lifetimeEvents = 50_000L,
},
},
}, TenantActivityResponse(full: false));
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -27,6 +27,8 @@ public class PlatformStatsStorageTests
{
public Mock<TableClient> Table { get; } = new();
public List<string> Filters { get; } = new();
/// <summary>The projection of every query, in order; null for a query without one.</summary>
public List<string[]?> Selects { get; } = new();
public List<List<TableTransactionAction>> Submitted { get; } = new();
public Func<string, TableEntity[]> RowsFor { get; set; } = _ => Array.Empty<TableEntity>();
public Queue<Exception> SubmitFailures { get; } = new();
@@ -35,9 +37,10 @@ public class PlatformStatsStorageTests
public Harness()
{
Table.Setup(c => c.QueryAsync<TableEntity>(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns((string filter, int? _, IEnumerable<string> _, CancellationToken _) =>
.Returns((string filter, int? _, IEnumerable<string>? select, CancellationToken _) =>
{
Filters.Add(filter);
Selects.Add(select?.ToArray());
var page = Page<TableEntity>.FromValues(RowsFor(filter), null, Mock.Of<Response>());
return AsyncPageable<TableEntity>.FromPages(new[] { page });
});
@@ -54,6 +57,7 @@ public class PlatformStatsStorageTests
var service = new Mock<TableServiceClient>();
service.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(Table.Object);
service.Setup(s => s.GetTableClient(Constants.TableNames.UsageMetrics)).Returns(Table.Object);
service.Setup(s => s.GetTableClient(Constants.TableNames.UserActivity)).Returns(Table.Object);
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
}
}
@@ -281,6 +285,76 @@ public class PlatformStatsStorageTests
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetTenantActivityDaysAsync(DateTime.UtcNow.Date, DateTime.UtcNow.Date));
}
[Fact]
public async Task Tenant_activity_days_project_and_carry_the_success_and_failure_counts()
{
var h = new Harness
{
RowsFor = _ => new[]
{
new TableEntity("2026-10-01", Tenant) { ["SessionsTotal"] = 5, ["SessionsSucceeded"] = 3, ["SessionsFailed"] = 1 },
new TableEntity("2026-10-02", Tenant) { ["SessionsTotal"] = 2 },
}
};
var days = await h.Sut.GetTenantActivityDaysAsync(
new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(new[] { "PartitionKey", "RowKey", "SessionsTotal", "SessionsSucceeded", "SessionsFailed" }, Assert.Single(h.Selects));
Assert.Collection(days,
d => Assert.Equal((5, 3, 1), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)),
d => Assert.Equal((2, 0, 0), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)));
}
// ---------- portal visits (tenant activity) ----------
private static TableEntity Login(string tenantId, string upn, string objectId, DateTime? at)
{
var row = new TableEntity(tenantId, Guid.NewGuid().ToString("N")) { ["Upn"] = upn, ["ObjectId"] = objectId };
if (at is { } loginAt) row["LoginAt"] = loginAt;
return row;
}
[Fact]
public async Task Portal_visits_are_people_since_the_cutoff_keyed_by_object_id_else_upn()
{
const string upperTenant = "AAAAAAAA-0000-4000-8000-000000000000";
var at = new DateTime(2026, 11, 5, 9, 30, 0, DateTimeKind.Utc);
var h = new Harness
{
RowsFor = _ => new[]
{
Login(upperTenant, "alice@contoso.example", "OID-ALICE", at),
Login(Tenant, "Bob@Contoso.example", "", at), // no object id: the UPN
Login(Tenant, "app:11111111-2222-3333-4444-555555555555", "sp-object-id", at), // a service principal
Login(Tenant, "", " ", at), // no identity at all
Login(Tenant, "carol@contoso.example", "oid-carol", null), // no visit time
}
};
var visits = await h.Sut.GetPortalVisitsSinceAsync(new DateTime(2026, 8, 7, 12, 0, 0, DateTimeKind.Utc));
Assert.Equal("LoginAt ge datetime'2026-08-07T12:00:00Z'", Assert.Single(h.Filters));
Assert.Equal(new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }, Assert.Single(h.Selects));
Assert.Collection(visits,
v =>
{
Assert.Equal(upperTenant.ToLowerInvariant(), v.TenantId);
Assert.Equal("oid-alice", v.PersonKey);
Assert.Equal(at, v.At);
Assert.Equal(DateTimeKind.Utc, v.At.Kind);
},
v => Assert.Equal((Tenant, "bob@contoso.example"), (v.TenantId, v.PersonKey)));
}
[Fact]
public async Task A_failing_portal_visit_read_throws_instead_of_reporting_nobody()
{
var h = new Harness { RowsFor = _ => throw new RequestFailedException(503, "busy") };
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetPortalVisitsSinceAsync(DateTime.UtcNow.AddDays(-90)));
}
// ---------- seen device models ----------
[Fact]
@@ -1471,6 +1471,8 @@ public partial class PolicyEnforcementMiddlewareTests
[InlineData("GET", "/api/global/distress-reports", TenantScoping.None)]
// ops-events is platform-OPERATIONAL data — GA/Reader-only by intent (not a can't-bound limit), so None.
[InlineData("GET", "/api/global/ops-events", TenantScoping.None)]
// Every tenant's use side by side (names, enrollments, portal users) — an operator view, never an MSP's.
[InlineData("GET", "/api/global/metrics/tenant-activity", TenantScoping.None)]
// Operator reads with a ?tenantId= filter: OperatorFilterReads above.
public void GlobalRoute_DelegatedAccessibility_MatchesContract(string method, string path, TenantScoping expected)
{
@@ -152,6 +152,10 @@ public sealed class SharedManifestParityTests
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
// Evidence keys per condition source: what a {{signal.key}} token can resolve (MCP rule lint).
["analyzeRuleEvidenceKeys"] = RuleEvidenceKeys.BySource,
// Tenant activity (GET global/metrics/tenant-activity): the lifecycle stages in their test order —
// the MCP tool's stage filter enumerates them — and the edition names the portal's plan facet uses.
["tenantActivityStages"] = TenantActivityStages.All,
["tenantEditions"] = TenantEditionNames.All,
// Vocabularies of the diagnosis headline (GET sessions/{id}/diagnosis): the portal's verdict
// texts and culprit labels key on the same values the builder writes.
["diagnosis"] = new Dictionary<string, object?>
@@ -0,0 +1,370 @@
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Counting rules of the tenant activity (GA → Metrics → Tenant Activity, MCP get_tenant_activity): the window is
/// the N full UTC days through the last aggregated day, every signup has exactly one lifecycle stage, dormant and
/// never-enrolled agree with the growth figures, the totals add up from the tenant rows, and the portal figures
/// count people inside the window only.
/// </summary>
public class TenantActivityBuilderTests
{
// Friday; yesterday (2026-11-05) is aggregated unless a test says otherwise.
private static readonly DateTime Now = new(2026, 11, 6, 15, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Today = Now.Date;
private static string Tid(char c) => $"{new string(c, 8)}-0000-4000-8000-000000000000";
private static DateTime DaysAgo(int days, int hour = 0) => Today.AddDays(-days).AddHours(hour);
private static TenantConfiguration Config(char id, DateTime? onboardedAt, string domain = "contoso.example")
=> new() { TenantId = Tid(id), DomainName = domain, OnboardedAt = onboardedAt };
private static TenantStats Stats(char id, long total, DateTime lastUpdated, long succeeded = 0, long events = 0)
=> new() { TenantId = Tid(id), TotalEnrollments = total, SuccessfulEnrollments = succeeded, TotalEventsProcessed = events, LastUpdated = lastUpdated };
private static TenantActivityDay Row(char id, int daysAgo, int total, int succeeded = 0, int failed = 0)
=> new() { TenantId = Tid(id), Day = DaysAgo(daysAgo), SessionsTotal = total, SessionsSucceeded = succeeded, SessionsFailed = failed };
private static PortalVisit Visit(char id, string person, DateTime at)
=> new() { TenantId = Tid(id), PersonKey = person, At = at };
private static GetTenantActivityResponse Build(
IReadOnlyList<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
IReadOnlyList<TenantActivityDay> rows,
int days = 30,
IReadOnlyList<PortalVisit>? visits = null,
bool portalAvailable = true,
bool yesterdayAggregated = true)
=> TenantActivityBuilder.Build(
configs, stats, rows, portalAvailable ? visits ?? Array.Empty<PortalVisit>() : null, Now, days, yesterdayAggregated);
private static TenantActivityTenantItem Tenant(GetTenantActivityResponse response, char id)
=> Assert.Single(response.Tenants, t => t.TenantId == Tid(id));
// ---------- window ----------
[Fact]
public void Window_is_the_n_full_days_through_yesterday_and_the_previous_window_before_it()
{
var response = Build(
new[] { Config('a', DaysAgo(100)) },
new[] { Stats('a', 7, DaysAgo(1, 9)) },
new[] { Row('a', 1, 2), Row('a', 30, 1), Row('a', 31, 4) });
Assert.Equal("2026-10-07", response.WindowStart);
Assert.Equal("2026-11-05", response.UsageDaysThrough);
Assert.Equal("2026-09-07", response.PreviousWindowStart);
Assert.Equal("2026-05-10", response.RowsFrom);
var tenant = Tenant(response, 'a');
Assert.Equal(3, tenant.Enrollments);
Assert.Equal(4, tenant.EnrollmentsPrevious);
Assert.Equal(30, response.Daily.Count);
Assert.Equal(("2026-10-07", 1), (response.Daily[0].Day, response.Daily[0].Enrollments));
Assert.Equal(("2026-11-05", 2), (response.Daily[^1].Day, response.Daily[^1].Enrollments));
}
[Theory]
[InlineData(1, "2026-11-05")]
[InlineData(7, "2026-10-30")]
[InlineData(30, "2026-10-07")]
[InlineData(90, "2026-08-08")]
public void The_window_holds_exactly_n_days_ending_yesterday(int days, string start)
{
var response = Build(Array.Empty<TenantConfiguration>(), Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(), days);
Assert.Equal(start, response.WindowStart);
Assert.Equal(days, response.Daily.Count);
Assert.Equal(start, response.Daily[0].Day);
Assert.Equal("2026-11-05", response.Daily[^1].Day);
}
[Fact]
public void Before_yesterday_is_aggregated_the_window_ends_the_day_before_and_yesterdays_counters_keep_a_tenant_active()
{
// Sessions only yesterday: the counters moved, the daily row is not written yet.
var configs = new[] { Config('b', DaysAgo(120)) };
var stats = new[] { Stats('b', 3, DaysAgo(1, 22)) };
var rows = new[] { Row('b', 40, 1) };
var early = Build(configs, stats, rows, yesterdayAggregated: false);
Assert.Equal("2026-11-04", early.UsageDaysThrough);
Assert.Equal("2026-10-06", early.WindowStart);
var tenant = Tenant(early, 'b');
Assert.Equal("2026-11-05", tenant.LastActiveDay);
Assert.Equal(TenantActivityStages.Quiet, tenant.Stage);
// Once aggregated, a day without a row had no sessions: the counter move alone keeps nobody active.
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'b').Stage);
}
// ---------- stages ----------
[Fact]
public void Every_signup_has_exactly_one_stage_in_order()
{
var configs = new[]
{
Config('n', DaysAgo(10)), // never enrolled
Config('d', DaysAgo(120)), // dormant: last sessions 40 days ago
Config('w', DaysAgo(20)), // new: first sessions 10 days ago
Config('p', DaysAgo(2)), // new: first sessions today, only the counters know
Config('c', DaysAgo(150)), // declining: 10 before, 4 now
Config('a', DaysAgo(300)), // active, signed up before the rows' horizon
Config('q', DaysAgo(100)), // quiet: only today's counters moved in 30 days
Config('g', DaysAgo(5), domain: ""), // a read of an unknown id, not a signup
};
var stats = new[]
{
Stats('d', 5, DaysAgo(40, 9)),
Stats('w', 2, DaysAgo(10, 9)),
Stats('p', 1, Today.AddHours(8)),
Stats('c', 30, DaysAgo(5, 9)),
Stats('a', 100, DaysAgo(1, 9)),
Stats('q', 4, Today.AddHours(7)),
Stats('g', 9, DaysAgo(1, 9)),
};
var rows = new[]
{
Row('d', 40, 2),
Row('w', 10, 2),
Row('c', 40, 10), Row('c', 5, 4),
Row('a', 35, 6), Row('a', 3, 6),
Row('q', 50, 1),
Row('g', 2, 9),
};
var response = Build(configs, stats, rows);
Assert.Equal(TenantActivityStages.NeverEnrolled, Tenant(response, 'n').Stage);
Assert.Equal(TenantActivityStages.Dormant, Tenant(response, 'd').Stage);
Assert.Equal(TenantActivityStages.New, Tenant(response, 'w').Stage);
Assert.Equal(TenantActivityStages.New, Tenant(response, 'p').Stage);
Assert.Equal(TenantActivityStages.Declining, Tenant(response, 'c').Stage);
Assert.Equal(TenantActivityStages.Active, Tenant(response, 'a').Stage);
Assert.Equal(TenantActivityStages.Quiet, Tenant(response, 'q').Stage);
Assert.DoesNotContain(response.Tenants, t => t.TenantId == Tid('g'));
var pending = Tenant(response, 'p');
Assert.Null(pending.FirstEnrollmentDay);
Assert.Equal("2026-11-06", pending.LastActiveDay);
Assert.Equal("2026-10-27", Tenant(response, 'w').FirstEnrollmentDay);
Assert.Null(Tenant(response, 'a').FirstEnrollmentDay); // signup before the rows' first day: unknown
var stages = response.Totals.Stages;
Assert.Equal((1, 1, 2, 1, 1, 1), (stages.NeverEnrolled, stages.Dormant, stages.New, stages.Declining, stages.Active, stages.Quiet));
Assert.Equal(response.Totals.SignedUpTenants,
stages.NeverEnrolled + stages.Dormant + stages.New + stages.Declining + stages.Active + stages.Quiet);
Assert.Equal(7, response.Totals.SignedUpTenants);
}
[Fact]
public void Dormant_wins_over_declining_and_a_short_window_leaves_recent_tenants_quiet()
{
var configs = new[] { Config('c', DaysAgo(150)), Config('q', DaysAgo(150)) };
var stats = new[] { Stats('c', 20, DaysAgo(40, 9)), Stats('q', 20, DaysAgo(10, 9)) };
var rows = new[] { Row('c', 40, 10), Row('q', 120, 5), Row('q', 10, 3) };
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'c').Stage);
Assert.Equal(TenantActivityStages.Quiet, Tenant(Build(configs, stats, rows, days: 7), 'q').Stage);
}
[Fact]
public void A_return_after_a_pause_is_not_new_for_a_signup_before_the_rows_horizon()
{
var response = Build(
new[] { Config('a', DaysAgo(200)) },
new[] { Stats('a', 40, DaysAgo(2, 9)) },
new[] { Row('a', 2, 3) });
var tenant = Tenant(response, 'a');
Assert.Equal(TenantActivityStages.Active, tenant.Stage);
Assert.Null(tenant.FirstEnrollmentDay);
}
[Fact]
public void Dormant_starts_at_30_days_without_activity_and_matches_the_growth_figures()
{
var configs = new[] { Config('x', DaysAgo(100)), Config('y', DaysAgo(100)), Config('n', DaysAgo(3)) };
var stats = new[] { Stats('x', 2, DaysAgo(29, 9)), Stats('y', 2, DaysAgo(30, 9)) };
var rows = new[] { Row('x', 29, 2), Row('y', 30, 2) };
var activity = Build(configs, stats, rows);
var growth = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, Array.Empty<string>());
Assert.NotEqual(TenantActivityStages.Dormant, Tenant(activity, 'x').Stage);
Assert.Equal(TenantActivityStages.Dormant, Tenant(activity, 'y').Stage);
Assert.Equal(growth.AllTenants.Dormant, activity.Totals.Stages.Dormant);
Assert.Equal(growth.AllTenants.NeverEnrolled, activity.Totals.Stages.NeverEnrolled);
Assert.Equal(1, activity.Totals.Stages.Dormant);
}
// ---------- figures ----------
[Fact]
public void Tenant_figures_and_totals_add_up_from_the_window_rows()
{
var configs = new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(100)), Config('c', DaysAgo(100)) };
var stats = new[]
{
Stats('a', 50, DaysAgo(1, 9), succeeded: 40, events: 9_000),
Stats('b', 8, DaysAgo(3, 9)),
Stats('c', 4, DaysAgo(45, 9)),
};
var rows = new[]
{
Row('a', 1, 4, succeeded: 3, failed: 1), Row('a', 2, 2, succeeded: 2),
Row('a', 40, 4),
Row('b', 3, 1),
Row('c', 45, 4),
};
var response = Build(configs, stats, rows);
var a = Tenant(response, 'a');
Assert.Equal((6, 4, 5, 1, 2), (a.Enrollments, a.EnrollmentsPrevious, a.Succeeded, a.Failed, a.ActiveDays));
Assert.Equal(50, a.TrendPercent);
Assert.Equal(83, a.SuccessPercent);
Assert.Equal((50L, 40L, 9_000L), (a.LifetimeEnrollments, a.LifetimeSucceeded, a.LifetimeEvents));
Assert.Equal(30, a.DailyEnrollments!.Length);
Assert.Equal(4, a.DailyEnrollments[^1]);
Assert.Equal(2, a.DailyEnrollments[^2]);
var b = Tenant(response, 'b');
Assert.Null(b.TrendPercent); // nothing in the previous window
Assert.Null(b.SuccessPercent); // no finished session
var c = Tenant(response, 'c');
Assert.Equal(-100, c.TrendPercent);
Assert.Null(c.DailyEnrollments);
var totals = response.Totals;
Assert.Equal((2, 2, 7, 8), (totals.ActiveTenants, totals.ActiveTenantsPrevious, totals.Enrollments, totals.EnrollmentsPrevious));
Assert.Equal(0, totals.ActiveTenantsTrendPercent);
Assert.Equal(-13, totals.EnrollmentsTrendPercent);
Assert.Equal((5, 1, (int?)83), (totals.Succeeded, totals.Failed, totals.SuccessPercent));
Assert.Equal(100, totals.Top10SharePercent);
Assert.Equal(totals.Enrollments, response.Daily.Sum(d => d.Enrollments));
Assert.Equal(1, response.Daily[^1].ActiveTenants);
Assert.Equal(2, response.Daily[^3].ActiveTenants + response.Daily[^1].ActiveTenants);
}
[Fact]
public void Tenants_come_by_window_enrollments_then_lifetime_and_the_top_10_share_needs_enrollments()
{
var configs = new[] { Config('a', DaysAgo(50)), Config('b', DaysAgo(50)), Config('c', DaysAgo(50)) };
var stats = new[] { Stats('a', 1, DaysAgo(45, 9)), Stats('b', 9, DaysAgo(45, 9)), Stats('c', 3, DaysAgo(45, 9)) };
var response = Build(configs, stats, new[] { Row('a', 45, 1), Row('b', 45, 9), Row('c', 45, 3) });
Assert.Equal(new[] { Tid('b'), Tid('c'), Tid('a') }, response.Tenants.Select(t => t.TenantId));
Assert.Null(response.Totals.Top10SharePercent);
Assert.Equal(-100, response.Totals.EnrollmentsTrendPercent);
}
[Fact]
public void Tenant_ids_join_case_insensitively()
{
var config = new TenantConfiguration { TenantId = Tid('a').ToUpperInvariant(), DomainName = "contoso.example", OnboardedAt = DaysAgo(60) };
var response = Build(new[] { config }, new[] { Stats('a', 5, DaysAgo(1, 9)) }, new[] { Row('a', 1, 5) },
visits: new[] { Visit('a', "oid-1", DaysAgo(2, 10)) });
var tenant = Assert.Single(response.Tenants);
Assert.Equal(5, tenant.Enrollments);
Assert.Equal(5, tenant.LifetimeEnrollments);
Assert.Equal(1, tenant.PortalUsers);
}
// ---------- portal ----------
[Fact]
public void Portal_users_count_people_inside_the_window_and_the_last_visit_takes_the_whole_history()
{
var visits = new[]
{
Visit('a', "oid-1", DaysAgo(30)), // the window's first day, midnight
Visit('a', "oid-1", DaysAgo(3, 8)), // the same person again
Visit('a', "oid-2", DaysAgo(31, 23)), // the day before the window
Visit('a', "oid-3", Today.AddHours(9)), // today, after the window
Visit('b', "oid-9", DaysAgo(60, 12)), // never enrolled, came back two months ago
};
var response = Build(
new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(80)) },
new[] { Stats('a', 3, DaysAgo(1, 9)) },
new[] { Row('a', 1, 3) },
visits: visits);
Assert.True(response.PortalActivityAvailable);
var a = Tenant(response, 'a');
Assert.Equal(1, a.PortalUsers);
Assert.Equal(Today.AddHours(9), a.LastPortalVisitAt);
var b = Tenant(response, 'b');
Assert.Equal(0, b.PortalUsers);
Assert.Equal(DaysAgo(60, 12), b.LastPortalVisitAt);
Assert.Equal(((int?)1, (int?)1), (response.Totals.PortalUsers, response.Totals.PortalTenants));
}
[Fact]
public void Without_the_sign_in_history_the_portal_figures_are_absent_not_zero()
{
var response = Build(new[] { Config('a', DaysAgo(100)) }, new[] { Stats('a', 3, DaysAgo(1, 9)) }, new[] { Row('a', 1, 3) },
portalAvailable: false);
Assert.False(response.PortalActivityAvailable);
Assert.Null(response.Totals.PortalUsers);
Assert.Null(response.Totals.PortalTenants);
var tenant = Assert.Single(response.Tenants);
Assert.Null(tenant.PortalUsers);
Assert.Null(tenant.LastPortalVisitAt);
Assert.Equal(3, tenant.Enrollments);
}
// ---------- identity ----------
[Fact]
public void Edition_follows_msp_then_plan_then_a_running_trial()
{
var msp = Config('m', DaysAgo(10));
msp.ManagedByProTenantId = Tid('z');
msp.PlanTier = "pro";
var pro = Config('p', DaysAgo(10));
pro.PlanTier = "pro";
var trial = Config('t', DaysAgo(10));
trial.TrialExpiresUtc = Now.AddDays(5);
var expired = Config('e', DaysAgo(10));
expired.TrialExpiresUtc = Now.AddDays(-1);
var community = Config('c', DaysAgo(10));
var response = Build(new[] { msp, pro, trial, expired, community }, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>());
Assert.Equal(TenantEditionNames.ProMsp, Tenant(response, 'm').Edition);
Assert.Equal(TenantEditionNames.Pro, Tenant(response, 'p').Edition);
Assert.Equal(TenantEditionNames.ProTrial, Tenant(response, 't').Edition);
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'e').Edition);
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'c').Edition);
}
[Fact]
public void A_tenant_row_carries_its_names_signup_and_counter_time()
{
var config = Config('a', DaysAgo(30, 10), domain: "fabrikam.example");
config.CompanyName = "Fabrikam";
var blank = Config('b', DaysAgo(30, 10));
blank.CompanyName = " ";
var response = Build(new[] { config, blank }, new[] { Stats('a', 1, DaysAgo(2, 11)) }, new[] { Row('a', 2, 1) });
var a = Tenant(response, 'a');
Assert.Equal(("fabrikam.example", "Fabrikam"), (a.DomainName, a.CompanyName));
Assert.Equal(DaysAgo(30, 10), a.OnboardedAt);
Assert.Equal(DaysAgo(2, 11), a.LastCounterUpdateAt);
var b = Tenant(response, 'b');
Assert.Null(b.CompanyName);
Assert.Null(b.LastCounterUpdateAt);
}
}
@@ -93,6 +93,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
public Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc)
=> _storage.GetPortalVisitsSinceAsync(sinceUtc);
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
@@ -1,4 +1,3 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
@@ -7,6 +6,7 @@ using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using static AutopilotMonitor.Functions.Functions.Metrics.TenantLifecycleFacts;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
@@ -50,13 +50,15 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var includeTenants = string.Equals(query["includeTenants"], "true", StringComparison.OrdinalIgnoreCase);
var nowUtc = DateTime.UtcNow;
var yesterday = nowUtc.Date.AddDays(-1);
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
var stats = await _metricsRepo.GetAllTenantStatsAsync();
var activity = await _metricsRepo.GetTenantActivityDaysAsync(
GrowthMetricsBuilder.UsageReadStart(nowUtc.Date, days), nowUtc.Date.AddDays(-1));
GrowthMetricsBuilder.UsageReadStart(nowUtc.Date, days), yesterday);
var yesterdayAggregated = await _metricsRepo.HasUsageMetricsSnapshotAsync(TenantLifecycleFacts.Key(yesterday));
return await req.OkAsync(GrowthMetricsBuilder.Build(
configs, stats, activity, nowUtc, days, timeZone, includeTenants, excludedTenantIds));
configs, stats, activity, nowUtc, days, timeZone, includeTenants, excludedTenantIds, yesterdayAggregated));
}
catch (Exception ex)
{
@@ -77,8 +79,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal const int DailyBucketMaxDays = 90;
internal const int PeakBaselineDays = 14;
internal const int PeakMinSignups = 3;
/// <summary>The activity segments look this far back, whatever the window.</summary>
internal const int ActivityDays = 30;
/// <summary>
/// OnboardedAt is the row's insert time from this day on; its one-time backfill gave the older rows
@@ -90,7 +90,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
/// <summary>First UTC day of the daily usage rows the response needs: the window and the activity look-back.</summary>
internal static DateTime UsageReadStart(DateTime today, int days) => InclusiveWindowStart(today, Math.Max(days, ActivityDays));
internal static DateTime UsageReadStart(DateTime today, int days)
=> InclusiveWindowStart(today, Math.Max(days, TenantLifecycleFacts.ActivityDays));
// IANA ids only ("Europe/Berlin", "America/Argentina/Buenos_Aires", "Etc/GMT+1"). On Linux the id
// becomes a path below the zoneinfo folder, so dots and rooted paths never reach the lookup.
@@ -127,20 +128,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return true;
}
private sealed class TenantFacts
{
public TenantConfiguration Config { get; init; } = default!;
public DateTime? SignupUtc { get; init; }
public bool Enrolled { get; init; }
public bool Succeeded { get; init; }
public DateTime? LastActiveDay { get; init; }
public DateTime? FirstEnrollmentDay { get; init; }
public bool FirstEnrollmentPending { get; init; }
public int? DaysToFirstEnrollment => FirstEnrollmentDay is { } first && SignupUtc is { } signup
? (int)(first - signup.Date).TotalDays
: null;
}
/// <param name="yesterdayAggregated">
/// Whether yesterday's daily rows exist yet (its platform row); before the first maintenance run after
/// midnight UTC they do not, and the counters alone tell who was active yesterday.
/// </param>
internal static GrowthMetricsResponse Build(
IReadOnlyList<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
@@ -149,34 +140,24 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
int days,
TimeZoneInfo timeZone,
bool includeTenants,
IReadOnlyCollection<string> excludedTenantIds)
IReadOnlyCollection<string> excludedTenantIds,
bool yesterdayAggregated = true)
{
var today = nowUtc.Date;
var windowStart = InclusiveWindowStart(today, days);
var previousStart = windowStart.AddDays(-days);
var statsByTenant = stats
.GroupBy(s => s.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var activeDaysByTenant = activityDays
.GroupBy(a => a.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(a => a.Day.Date).Distinct().OrderBy(d => d).ToList(),
StringComparer.OrdinalIgnoreCase);
var usageDaysThrough = TenantLifecycleFacts.UsageDaysThrough(today, yesterdayAggregated);
// A configuration row without a domain is a read of an unknown tenant id, not a signup.
var signedUp = configs.Where(c => !string.IsNullOrEmpty(c.DomainName)).ToList();
var signedUp = TenantLifecycleFacts.SignedUp(configs);
var excluded = new HashSet<string>(excludedTenantIds, StringComparer.OrdinalIgnoreCase);
var tenants = signedUp
.Where(c => !excluded.Contains(c.TenantId))
.Select(c => Facts(c, statsByTenant, activeDaysByTenant, today))
.ToList();
var tenants = TenantLifecycleFacts.For(
signedUp.Where(c => !excluded.Contains(c.TenantId)),
stats, activityDays, usageDaysThrough, UsageReadStart(today, days));
var window = tenants
.Where(t => t.SignupUtc is { } s && s.Date >= windowStart && s.Date <= today)
.OrderBy(t => t.SignupUtc)
.ToList();
var activeFrom7 = today.AddDays(-6);
var activeFrom30 = today.AddDays(-(ActivityDays - 1));
return new GrowthMetricsResponse
{
@@ -185,7 +166,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
WindowEnd = Key(today),
TimeZone = timeZone.Id,
ExactSignupTimesFrom = Key(ExactSignupTimesFrom),
UsageDaysThrough = Key(today.AddDays(-1)),
UsageDaysThrough = Key(usageDaysThrough),
ExcludedTenants = signedUp.Count - tenants.Count,
Signups = BuildSignups(tenants, window, windowStart, previousStart, today, days, timeZone),
Cohorts = window
@@ -197,7 +178,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
Signups = g.Count(),
Enrolled = g.Count(t => t.Enrolled),
Succeeded = g.Count(t => t.Succeeded),
ActiveLast7Days = g.Count(t => t.LastActiveDay >= activeFrom7),
ActiveLast7Days = g.Count(t => t.ActiveSince(activeFrom7)),
MedianDaysToFirstEnrollment = Median(g.Select(t => t.DaysToFirstEnrollment)),
})
.ToList(),
@@ -220,9 +201,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
NeverEnrolled = tenants.Count(t => !t.Enrolled),
Enrolled = tenants.Count(t => t.Enrolled),
EnrolledWithoutSuccess = tenants.Count(t => t.Enrolled && !t.Succeeded),
Active7Days = tenants.Count(t => t.LastActiveDay >= activeFrom7),
Active30Days = tenants.Count(t => t.LastActiveDay >= activeFrom30),
Dormant = tenants.Count(t => t.Enrolled && !(t.LastActiveDay >= activeFrom30)),
Active7Days = tenants.Count(t => t.ActiveSince(activeFrom7)),
Active30Days = tenants.Count(t => t.ActiveSince(TenantLifecycleFacts.ActivityFrom(today))),
Dormant = tenants.Count(t => t.IsDormant(today)),
},
Plans = new GrowthPlans
{
@@ -252,38 +233,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
};
}
private static TenantFacts Facts(
TenantConfiguration config,
IReadOnlyDictionary<string, TenantStats> statsByTenant,
IReadOnlyDictionary<string, List<DateTime>> activeDaysByTenant,
DateTime today)
{
DateTime? signup = config.OnboardedAt is { } at ? Utc(at) : null;
statsByTenant.TryGetValue(config.TenantId, out var counters);
var activeDays = activeDaysByTenant.TryGetValue(config.TenantId, out var found) ? found : new List<DateTime>();
// The daily rows end yesterday; counters that moved today mean sessions today.
var activeToday = counters != null && Utc(counters.LastUpdated) >= today;
DateTime? lastActive = activeToday ? today : activeDays.Count > 0 ? activeDays[^1] : null;
var enrolled = counters?.TotalEnrollments > 0;
var firstDay = signup is { } s
? activeDays.Where(d => d >= s.Date).Select(d => (DateTime?)d).FirstOrDefault()
: null;
return new TenantFacts
{
Config = config,
SignupUtc = signup,
Enrolled = enrolled,
Succeeded = counters?.SuccessfulEnrollments > 0,
LastActiveDay = lastActive,
FirstEnrollmentDay = firstDay,
FirstEnrollmentPending = enrolled && firstDay == null && activeToday,
};
}
private static GrowthSignups BuildSignups(
IReadOnlyList<TenantFacts> tenants,
IReadOnlyList<TenantFacts> window,
IReadOnlyList<TenantLifecycleFacts> tenants,
IReadOnlyList<TenantLifecycleFacts> window,
DateTime windowStart,
DateTime previousStart,
DateTime today,
@@ -345,12 +297,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
private static DateTime WeekStart(DateTime day) => day.AddDays(-(((int)day.DayOfWeek + 6) % 7));
private static DateTime Utc(DateTime value)
=> value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc);
private static string PlanTierOf(TenantConfiguration config)
=> string.IsNullOrEmpty(config.PlanTier) ? "free" : config.PlanTier;
private static string Key(DateTime day) => day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,305 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using static AutopilotMonitor.Functions.Functions.Metrics.TenantLifecycleFacts;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Operator-only tenant activity (GA → Metrics → Tenant Activity and the MCP tool get_tenant_activity): how
/// every signed-up tenant used the product in the last N full UTC days against the N days before —
/// enrollments, success, active days, lifecycle stage and portal users. The configuration rows, the per-tenant
/// counters, the daily usage rows and the portal sign-in history are joined here, once for both consumers.
/// </summary>
public class GetTenantActivityFunction
{
private readonly ILogger<GetTenantActivityFunction> _logger;
private readonly TenantConfigurationService _tenantConfigService;
private readonly IMetricsRepository _metricsRepo;
public GetTenantActivityFunction(
ILogger<GetTenantActivityFunction> logger,
TenantConfigurationService tenantConfigService,
IMetricsRepository metricsRepo)
{
_logger = logger;
_tenantConfigService = tenantConfigService;
_metricsRepo = metricsRepo;
}
[Function("GetTenantActivity")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/tenant-activity")] HttpRequestData req)
{
try
{
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var days = QueryParams.Int(req.Query["days"], TenantActivityBuilder.DefaultWindowDays, 1, TenantActivityBuilder.MaxWindowDays);
var nowUtc = DateTime.UtcNow;
var yesterday = nowUtc.Date.AddDays(-1);
// The configuration rows, the counters and the daily rows are not fail-soft (an empty list
// would read as "no tenant was active"); the sign-in history is optional.
var configsTask = _tenantConfigService.GetAllConfigurationsAsync();
var statsTask = _metricsRepo.GetAllTenantStatsAsync();
var rowsTask = _metricsRepo.GetTenantActivityDaysAsync(TenantActivityBuilder.RowsFrom(nowUtc.Date), yesterday);
var aggregatedTask = _metricsRepo.HasUsageMetricsSnapshotAsync(Key(yesterday));
var visitsTask = ReadPortalVisitsAsync(nowUtc);
await Task.WhenAll(configsTask, statsTask, rowsTask, aggregatedTask, visitsTask);
return await req.OkAsync(TenantActivityBuilder.Build(
await configsTask, await statsTask, await rowsTask, await visitsTask, nowUtc, days, await aggregatedTask));
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "GetTenantActivity");
}
}
/// <summary>The portal visits of the sign-in history, or null when it could not be read.</summary>
private async Task<IReadOnlyList<PortalVisit>?> ReadPortalVisitsAsync(DateTime nowUtc)
{
try
{
return await _metricsRepo.GetPortalVisitsSinceAsync(nowUtc.AddDays(-TenantActivityBuilder.PortalHistoryDays));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[TenantActivity] portal activity read failed");
return null;
}
}
}
/// <summary>
/// Pure response builder — internal static so the counting rules are pinned by tests. The DTOs live in
/// AutopilotMonitor.Shared.Models (Models/Metrics/TenantActivityApiModels.cs); the facts every tenant shares
/// with the growth figures come from <see cref="TenantLifecycleFacts"/>.
/// </summary>
internal static class TenantActivityBuilder
{
internal const int DefaultWindowDays = 30;
/// <summary>The window and the previous window together fit into the 180 days the daily rows keep.</summary>
internal const int MaxWindowDays = DataLifetimes.UsageMetricsDays / 2;
/// <summary>The sign-in history keeps 90 days; the last portal visit is taken from all of it.</summary>
internal const int PortalHistoryDays = 90;
/// <summary>A tenant is declining from this many enrollments in the previous window on.</summary>
internal const int DecliningMinPrevious = 5;
internal const int TopShareTenants = 10;
private static readonly List<TenantActivityDay> NoRows = new();
private static readonly List<PortalVisit> NoVisits = new();
/// <summary>First day of the daily rows the builder reads: all the rows keep, for the first day with sessions.</summary>
internal static DateTime RowsFrom(DateTime today) => today.AddDays(-DataLifetimes.UsageMetricsDays);
private sealed record Window(int Days, DateTime Start, DateTime Through, DateTime PreviousStart)
{
public bool Contains(DateTime day) => day >= Start && day <= Through;
public bool ContainsPrevious(DateTime day) => day >= PreviousStart && day < Start;
}
/// <param name="visits">The portal visits of the sign-in history; null when it could not be read.</param>
/// <param name="yesterdayAggregated">Whether yesterday's daily rows exist yet (its platform row).</param>
internal static GetTenantActivityResponse Build(
IReadOnlyList<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
IReadOnlyList<TenantActivityDay> rows,
IReadOnlyList<PortalVisit>? visits,
DateTime nowUtc,
int days,
bool yesterdayAggregated)
{
var today = nowUtc.Date;
var through = UsageDaysThrough(today, yesterdayAggregated);
var start = through.AddDays(-(days - 1));
var window = new Window(days, start, through, start.AddDays(-days));
var rowsFrom = RowsFrom(today);
var facts = For(SignedUp(configs), stats, rows, through, rowsFrom);
var signedUpIds = new HashSet<string>(facts.Select(f => f.Config.TenantId), StringComparer.OrdinalIgnoreCase);
var countedRows = rows
.Where(r => signedUpIds.Contains(r.TenantId) && (window.Contains(r.Day) || window.ContainsPrevious(r.Day)))
.ToList();
var rowsByTenant = countedRows
.GroupBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var visitsByTenant = visits?
.GroupBy(v => v.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var tenants = facts
.Select(f => Tenant(f, window, rowsByTenant, visitsByTenant, today, nowUtc))
.OrderByDescending(t => t.Enrollments)
.ThenByDescending(t => t.LifetimeEnrollments)
.ThenBy(t => t.TenantId, StringComparer.OrdinalIgnoreCase)
.ToList();
return new GetTenantActivityResponse
{
WindowDays = days,
WindowStart = Key(window.Start),
UsageDaysThrough = Key(window.Through),
PreviousWindowStart = Key(window.PreviousStart),
RowsFrom = Key(rowsFrom),
PortalActivityAvailable = visits != null,
Totals = Totals(tenants, visits != null),
Daily = Daily(countedRows.Where(r => window.Contains(r.Day)), window),
Tenants = tenants,
};
}
private static TenantActivityTenantItem Tenant(
TenantLifecycleFacts facts,
Window window,
IReadOnlyDictionary<string, List<TenantActivityDay>> rowsByTenant,
IReadOnlyDictionary<string, List<PortalVisit>>? visitsByTenant,
DateTime today,
DateTime nowUtc)
{
var config = facts.Config;
var tenantRows = rowsByTenant.TryGetValue(config.TenantId, out var found) ? found : NoRows;
var inWindow = tenantRows.Where(r => window.Contains(r.Day)).ToList();
var enrollments = inWindow.Sum(r => r.SessionsTotal);
var previous = tenantRows.Where(r => window.ContainsPrevious(r.Day)).Sum(r => r.SessionsTotal);
var succeeded = inWindow.Sum(r => r.SessionsSucceeded);
var failed = inWindow.Sum(r => r.SessionsFailed);
int[]? daily = null;
if (enrollments > 0)
{
daily = new int[window.Days];
foreach (var row in inWindow)
daily[(int)(row.Day.Date - window.Start).TotalDays] += row.SessionsTotal;
}
int? portalUsers = null;
DateTime? lastVisit = null;
if (visitsByTenant != null)
{
var tenantVisits = visitsByTenant.TryGetValue(config.TenantId, out var v) ? v : NoVisits;
var windowEnd = window.Through.AddDays(1);
portalUsers = tenantVisits
.Where(x => x.At >= window.Start && x.At < windowEnd)
.Select(x => x.PersonKey)
.Distinct(StringComparer.Ordinal)
.Count();
lastVisit = tenantVisits.Count > 0 ? tenantVisits.Max(x => x.At) : null;
}
var counters = facts.Counters;
return new TenantActivityTenantItem
{
TenantId = config.TenantId,
DomainName = config.DomainName,
CompanyName = string.IsNullOrWhiteSpace(config.CompanyName) ? null : config.CompanyName,
Edition = FeatureEntitlementCatalog.Resolve(config, nowUtc).FacetName,
Stage = StageOf(facts, enrollments, previous, window, today),
OnboardedAt = facts.SignupUtc,
Enrollments = enrollments,
EnrollmentsPrevious = previous,
TrendPercent = TrendPercent(enrollments, previous),
Succeeded = succeeded,
Failed = failed,
SuccessPercent = Percent(succeeded, succeeded + failed),
ActiveDays = inWindow.Select(r => r.Day.Date).Distinct().Count(),
DailyEnrollments = daily,
FirstEnrollmentDay = facts.FirstEnrollmentDay is { } first ? Key(first) : null,
LastActiveDay = facts.LastActiveDay is { } last ? Key(last) : null,
LastCounterUpdateAt = counters != null && counters.LastUpdated > DateTime.MinValue ? Utc(counters.LastUpdated) : null,
LifetimeEnrollments = counters?.TotalEnrollments ?? 0,
LifetimeSucceeded = counters?.SuccessfulEnrollments ?? 0,
LifetimeEvents = counters?.TotalEventsProcessed ?? 0,
PortalUsers = portalUsers,
LastPortalVisitAt = lastVisit,
};
}
/// <summary>The first of <see cref="TenantActivityStages.All"/> that applies.</summary>
private static string StageOf(TenantLifecycleFacts facts, int enrollments, int previous, Window window, DateTime today)
{
if (!facts.Enrolled) return TenantActivityStages.NeverEnrolled;
if (facts.IsDormant(today)) return TenantActivityStages.Dormant;
if (facts.FirstEnrollmentPending || facts.FirstEnrollmentDay is { } first && window.Contains(first))
return TenantActivityStages.New;
if (previous >= DecliningMinPrevious && enrollments * 2 <= previous) return TenantActivityStages.Declining;
return enrollments > 0 ? TenantActivityStages.Active : TenantActivityStages.Quiet;
}
private static TenantActivityTotals Totals(IReadOnlyList<TenantActivityTenantItem> tenants, bool portalAvailable)
{
var enrollments = tenants.Sum(t => t.Enrollments);
var previous = tenants.Sum(t => t.EnrollmentsPrevious);
var active = tenants.Count(t => t.Enrollments > 0);
var activePrevious = tenants.Count(t => t.EnrollmentsPrevious > 0);
var succeeded = tenants.Sum(t => t.Succeeded);
var failed = tenants.Sum(t => t.Failed);
int CountStage(string stage) => tenants.Count(t => t.Stage == stage);
return new TenantActivityTotals
{
SignedUpTenants = tenants.Count,
ActiveTenants = active,
ActiveTenantsPrevious = activePrevious,
ActiveTenantsTrendPercent = TrendPercent(active, activePrevious),
Enrollments = enrollments,
EnrollmentsPrevious = previous,
EnrollmentsTrendPercent = TrendPercent(enrollments, previous),
Succeeded = succeeded,
Failed = failed,
SuccessPercent = Percent(succeeded, succeeded + failed),
// The tenants come sorted by enrollments, most first.
Top10SharePercent = Percent(tenants.Take(TopShareTenants).Sum(t => t.Enrollments), enrollments),
Stages = new TenantActivityStageCounts
{
NeverEnrolled = CountStage(TenantActivityStages.NeverEnrolled),
Dormant = CountStage(TenantActivityStages.Dormant),
New = CountStage(TenantActivityStages.New),
Declining = CountStage(TenantActivityStages.Declining),
Active = CountStage(TenantActivityStages.Active),
Quiet = CountStage(TenantActivityStages.Quiet),
},
PortalUsers = portalAvailable ? tenants.Sum(t => t.PortalUsers ?? 0) : null,
PortalTenants = portalAvailable ? tenants.Count(t => t.PortalUsers > 0) : null,
};
}
private static List<TenantActivityDailyItem> Daily(IEnumerable<TenantActivityDay> windowRows, Window window)
{
var byDay = windowRows
.GroupBy(r => r.Day.Date)
.ToDictionary(g => g.Key, g => g.ToList());
var daily = new List<TenantActivityDailyItem>(window.Days);
for (var day = window.Start; day <= window.Through; day = day.AddDays(1))
{
var dayRows = byDay.TryGetValue(day, out var found) ? found : NoRows;
daily.Add(new TenantActivityDailyItem
{
Day = Key(day),
Enrollments = dayRows.Sum(r => r.SessionsTotal),
Succeeded = dayRows.Sum(r => r.SessionsSucceeded),
Failed = dayRows.Sum(r => r.SessionsFailed),
ActiveTenants = dayRows.Select(r => r.TenantId).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
});
}
return daily;
}
/// <summary><paramref name="part"/> of <paramref name="whole"/> in whole percent; null when the whole is zero.</summary>
internal static int? Percent(long part, long whole)
=> whole > 0 ? (int)Math.Round(100.0 * part / whole, MidpointRounding.AwayFromZero) : null;
/// <summary>Change from <paramref name="previous"/> to <paramref name="current"/> in whole percent; null without a previous value.</summary>
internal static int? TrendPercent(int current, int previous)
=> previous > 0 ? (int)Math.Round(100.0 * (current - previous) / previous, MidpointRounding.AwayFromZero) : null;
}
}
@@ -0,0 +1,127 @@
using System.Globalization;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// What the growth figures and the tenant activity both know about one signed-up tenant, from its
/// configuration row, its lifetime counters (PlatformStats tenant/current) and the daily UsageMetrics rows.
/// One place, so both answer "enrolled", "last active", "first day with sessions" and "dormant" identically —
/// the GA page and the growth report are checked against each other.
/// </summary>
internal sealed class TenantLifecycleFacts
{
/// <summary>A tenant without activity in this many UTC days, today included, is dormant.</summary>
internal const int ActivityDays = 30;
private static readonly List<DateTime> NoDays = new();
public TenantConfiguration Config { get; init; } = default!;
public TenantStats? Counters { get; init; }
public DateTime? SignupUtc { get; init; }
/// <summary>The lifetime counter holds at least one enrollment.</summary>
public bool Enrolled { get; init; }
/// <summary>The lifetime counter holds at least one Succeeded session.</summary>
public bool Succeeded { get; init; }
/// <summary>
/// Latest UTC day with sessions: the day the counters last moved when that lies after the last daily row
/// (sessions the rows have not seen yet), else the last daily row.
/// </summary>
public DateTime? LastActiveDay { get; init; }
/// <summary>
/// First UTC day with sessions since signup; null while only the counters have seen them (pending) and for
/// a signup before the rows' first day (unknown).
/// </summary>
public DateTime? FirstEnrollmentDay { get; init; }
/// <summary>Enrolled without a daily row since signup, but the counters moved after the last row.</summary>
public bool FirstEnrollmentPending { get; init; }
public int? DaysToFirstEnrollment => FirstEnrollmentDay is { } first && SignupUtc is { } signup
? (int)(first - signup.Date).TotalDays
: null;
/// <summary>Active on <paramref name="day"/> or later.</summary>
public bool ActiveSince(DateTime day) => LastActiveDay >= day;
/// <summary>Enrolled, but no activity in the last <see cref="ActivityDays"/> UTC days, today included.</summary>
public bool IsDormant(DateTime today) => Enrolled && !ActiveSince(ActivityFrom(today));
/// <summary>First day of the dormant look-back: today and the <see cref="ActivityDays"/> − 1 days before.</summary>
internal static DateTime ActivityFrom(DateTime today) => today.AddDays(-(ActivityDays - 1));
/// <summary>
/// The last day the daily rows cover: yesterday once it is aggregated (its platform row exists), else the
/// day before — the first maintenance run after midnight UTC writes yesterday's rows.
/// </summary>
internal static DateTime UsageDaysThrough(DateTime today, bool yesterdayAggregated)
=> today.AddDays(yesterdayAggregated ? -1 : -2);
/// <summary>A configuration row without a domain is a read of an unknown tenant id, not a signup.</summary>
internal static List<TenantConfiguration> SignedUp(IEnumerable<TenantConfiguration> configs)
=> configs.Where(c => !string.IsNullOrEmpty(c.DomainName)).ToList();
/// <summary>
/// The facts of every given tenant. <paramref name="rows"/> are the daily rows from <paramref name="rowsFrom"/>
/// through <paramref name="usageDaysThrough"/>; a first day with sessions before <paramref name="rowsFrom"/>
/// cannot be told, so it stays unknown for an older signup. Tenant ids join case-insensitively.
/// </summary>
internal static List<TenantLifecycleFacts> For(
IEnumerable<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
IReadOnlyList<TenantActivityDay> rows,
DateTime usageDaysThrough,
DateTime rowsFrom)
{
var countersByTenant = stats
.GroupBy(s => s.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var daysByTenant = rows
.GroupBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(r => r.Day.Date).Distinct().OrderBy(d => d).ToList(),
StringComparer.OrdinalIgnoreCase);
return configs
.Select(c => Of(c, countersByTenant, daysByTenant, usageDaysThrough, rowsFrom))
.ToList();
}
private static TenantLifecycleFacts Of(
TenantConfiguration config,
IReadOnlyDictionary<string, TenantStats> countersByTenant,
IReadOnlyDictionary<string, List<DateTime>> daysByTenant,
DateTime usageDaysThrough,
DateTime rowsFrom)
{
DateTime? signup = config.OnboardedAt is { } at ? Utc(at) : null;
countersByTenant.TryGetValue(config.TenantId, out var counters);
var days = daysByTenant.TryGetValue(config.TenantId, out var found) ? found : NoDays;
// The rows end at usageDaysThrough; counters that moved after it saw sessions the rows have not.
DateTime? countersDay = counters != null ? Utc(counters.LastUpdated).Date : null;
var movedAfterRows = countersDay > usageDaysThrough;
DateTime? lastActive = movedAfterRows ? countersDay : days.Count > 0 ? days[^1] : null;
var enrolled = counters?.TotalEnrollments > 0;
var knowable = signup is { } s && s.Date >= rowsFrom;
var firstDay = knowable
? days.Where(d => d >= signup!.Value.Date).Select(d => (DateTime?)d).FirstOrDefault()
: null;
return new TenantLifecycleFacts
{
Config = config,
Counters = counters,
SignupUtc = signup,
Enrolled = enrolled,
Succeeded = counters?.SuccessfulEnrollments > 0,
LastActiveDay = lastActive,
FirstEnrollmentDay = firstDay,
FirstEnrollmentPending = knowable && enrolled && firstDay == null && movedAfterRows,
};
}
internal static DateTime Utc(DateTime value)
=> value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc);
internal static string Key(DateTime day) => day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
@@ -589,6 +589,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/verdict-calibration", EndpointPolicy.GlobalReadOrAdmin),
// Operator growth figures across all tenants (signups, activation, plan state); no tenant parameter.
new("GET", "global/metrics/growth", EndpointPolicy.GlobalReadOrAdmin),
// Operator view of every tenant's use (enrollments, lifecycle stage, portal users); no tenant parameter,
// so a delegated (MSP) reader never reaches it.
new("GET", "global/metrics/tenant-activity", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
@@ -45,6 +45,17 @@ namespace AutopilotMonitor.Functions.Security
public string SourceName => Source.ToString().ToLowerInvariant();
/// <summary>Wire spelling of <see cref="Edition"/> ("community" | "pro").</summary>
public string EditionName => Edition.ToString().ToLowerInvariant();
/// <summary>
/// The portal's plan facet value (<see cref="TenantEditionNames"/>), by the display precedence of
/// <see cref="Source"/>: Msp "pro-msp", Plan "pro", Trial "pro-trial", else "community".
/// </summary>
public string FacetName => Source switch
{
EditionSource.Msp => TenantEditionNames.ProMsp,
EditionSource.Plan => TenantEditionNames.Pro,
EditionSource.Trial => TenantEditionNames.ProTrial,
_ => TenantEditionNames.Community,
};
}
/// <summary>
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Tenant days with sessions between two UTC days (both inclusive): one UsageMetrics row per
/// tenant and day, the platform row excluded, only the session count read. Not fail-soft —
/// tenant and day, the platform row excluded, only the session counts read. Not fail-soft —
/// a swallowed failure would read as "no tenant was active".
/// </summary>
public async Task<List<TenantActivityDay>> GetTenantActivityDaysAsync(DateTime fromDay, DateTime toDay)
@@ -83,16 +83,27 @@ namespace AutopilotMonitor.Functions.Services
+ $" and RowKey ne '{UsageMetricsPlatformRowKey}'";
var result = new List<TenantActivityDay>();
await foreach (var row in tableClient.QueryAsync<TableEntity>(
filter: filter, select: new[] { "PartitionKey", "RowKey", "SessionsTotal" }).ConfigureAwait(false))
filter: filter, select: TenantActivityDayColumns).ConfigureAwait(false))
{
var sessions = row.GetInt32("SessionsTotal") ?? 0;
if (sessions > 0 && DateTime.TryParseExact(row.PartitionKey, UsageDayFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var day))
result.Add(new TenantActivityDay { Day = day, TenantId = row.RowKey, SessionsTotal = sessions });
result.Add(new TenantActivityDay
{
Day = day,
TenantId = row.RowKey,
SessionsTotal = sessions,
SessionsSucceeded = row.GetInt32("SessionsSucceeded") ?? 0,
SessionsFailed = row.GetInt32("SessionsFailed") ?? 0,
});
}
return result;
}
/// <summary>The columns <see cref="GetTenantActivityDaysAsync"/> reads of a daily UsageMetrics row.</summary>
internal static readonly string[] TenantActivityDayColumns =
{ "PartitionKey", "RowKey", "SessionsTotal", "SessionsSucceeded", "SessionsFailed" };
private const string UsageDayFormat = "yyyy-MM-dd";
private static string UsageDayKey(DateTime day) => day.ToString(UsageDayFormat, CultureInfo.InvariantCulture);
@@ -1611,6 +1622,46 @@ namespace AutopilotMonitor.Functions.Services
return result.Values.ToList();
}
/// <summary>
/// Every portal visit of a person since a point in time — see IMetricsRepository. LoginAt is not a key, so
/// this is one filtered scan over every partition, projected to the four columns it reads; the table keeps
/// 90 days. Not fail-soft: an empty answer would read as "nobody visited".
/// </summary>
public async Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var filter = $"LoginAt ge datetime'{sinceUtc:yyyy-MM-ddTHH:mm:ss}Z'";
var visits = new List<PortalVisit>();
await foreach (var row in tableClient.QueryAsync<TableEntity>(
filter: filter, select: PortalVisitColumns).ConfigureAwait(false))
{
var upn = row.GetString("Upn");
var person = PortalPersonKey(row.GetString("ObjectId"), upn);
var at = row.GetDateTime("LoginAt");
// A service principal's MCP session is recorded here too (McpUserFunction); it is no person.
if (person == null || at == null || Constants.PrincipalKeys.IsApplication(upn))
continue;
visits.Add(new PortalVisit
{
TenantId = row.PartitionKey.ToLowerInvariant(),
PersonKey = person,
At = DateTime.SpecifyKind(at.Value, DateTimeKind.Utc),
});
}
return visits;
}
/// <summary>The columns <see cref="GetPortalVisitsSinceAsync"/> reads of a UserActivity row.</summary>
internal static readonly string[] PortalVisitColumns = { "PartitionKey", "Upn", "ObjectId", "LoginAt" };
/// <summary>The person behind a sign-in row: its object id, else its UPN, lower-cased; null without either.</summary>
internal static string? PortalPersonKey(string? objectId, string? upn)
{
if (!string.IsNullOrWhiteSpace(objectId))
return objectId.Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(upn) ? null : upn.Trim().ToLowerInvariant();
}
/// <summary>
/// Gets user activity metrics for a specific tenant
/// </summary>
@@ -40,11 +40,10 @@ interface Role {
maxCatalogChars: number;
}
const ROLES: Role[] = [
// Raised 2026-10-28 by +1,100 each: the platform-only get_growth_metrics (measured 1,106 chars).
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 93_100 },
// Raised 2026-11-05 by +200: get_api_usage reads the MCP usage overview — eight views, the window, the
// platform-admin switch and two drill-down ids (measured 78,205 chars; the Global Admin catalog stayed under).
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_300 },
// Raised 2026-11-06 by +1,761 each: get_tenant_activity, the platform-only usage per signed-up customer tenant —
// three views, the window and one lifecycle stage (measured 94,774 and 79,966 chars).
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 94_861 },
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 80_061 },
// Raised 2026-11-04 by +400: search_sessions gained the groupTag filter and five Autopilot
// projection keys (measured 53,318 chars; the other roles stayed under their ceilings).
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_400 },
@@ -0,0 +1,433 @@
/**
* get_tenant_activity: the shaping of the tenant activity views (tenant-activity-shape.ts) and the tool's routing.
* - rows are slim: a ranking row carries the ranking columns, a lifecycle row the figures of its stage;
* - absent wire values become explicit nulls (no previous enrollments, no finished session, no sign-in history);
* - every lifecycle list has its own order, and equal keys keep the backend order;
* - every list is cut to its cap and states its full length, and a realistic platform stays under the response cap;
* - platform roles only; days reaches the backend, view and stage stay in the MCP server.
*
* apiFetch is mocked so these run with no backend / token.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
vi.mock('../client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../client.js')>();
return { ...actual, apiFetch: apiFetchMock };
});
import { registerAdminTools } from '../tools/admin.js';
import { MAX_RESULT_SIZE_CHARS } from '../tools/shared.js';
import { LIFECYCLE_LISTS, TENANT_ACTIVITY_CAPS, TENANT_ACTIVITY_VIEWS, shapeTenantActivity, type TenantActivityView } from '../tenant-activity-shape.js';
import { TENANT_ACTIVITY_STAGES, type TenantActivityStage } from '../generated/wire-vocabularies.generated.js';
import type { GetTenantActivityResponse, TenantActivityTenantItem } from '../generated/wire-types.generated.js';
type Row = Record<string, unknown>;
interface List { total: number; truncated: boolean; rows: Row[] }
/** Placeholder tenant ids: a GUID per index, never a real tenant. */
const tenantId = (i: number) => `aaaaaaaa-0000-4000-8000-${String(i).padStart(12, '0')}`;
function tenant(i: number, over: Partial<TenantActivityTenantItem> = {}): TenantActivityTenantItem {
return {
tenantId: tenantId(i),
domainName: `tenant${i}.contoso.example`,
edition: 'community',
stage: 'active',
enrollments: 0,
enrollmentsPrevious: 0,
succeeded: 0,
failed: 0,
activeDays: 0,
lifetimeEnrollments: 0,
lifetimeSucceeded: 0,
lifetimeEvents: 0,
...over,
};
}
function response(tenants: TenantActivityTenantItem[], over: Partial<GetTenantActivityResponse> = {}): GetTenantActivityResponse {
return {
windowDays: 30,
windowStart: '2026-10-07',
usageDaysThrough: '2026-11-05',
previousWindowStart: '2026-09-07',
rowsFrom: '2026-05-10',
portalActivityAvailable: true,
totals: {
signedUpTenants: tenants.length,
activeTenants: 2,
activeTenantsPrevious: 1,
activeTenantsTrendPercent: 100,
enrollments: 43,
enrollmentsPrevious: 30,
enrollmentsTrendPercent: 43,
succeeded: 38,
failed: 2,
successPercent: 95,
top10SharePercent: 100,
stages: { neverEnrolled: 1, dormant: 0, new: 0, declining: 0, active: 2, quiet: 0 },
portalUsers: 5,
portalTenants: 2,
},
daily: [
{ day: '2026-11-04', enrollments: 0, succeeded: 0, failed: 0, activeTenants: 0 },
{ day: '2026-11-05', enrollments: 43, succeeded: 38, failed: 2, activeTenants: 2 },
],
tenants,
...over,
};
}
const stagesOf = (out: Row) => out.stages as Record<string, List>;
const ids = (list: List) => list.rows.map((r) => r.tenantId);
// ── Summary ───────────────────────────────────────────────────────────────────
describe('shapeTenantActivity — summary', () => {
const contoso = tenant(1, {
domainName: 'contoso.example', companyName: 'Contoso Ltd', edition: 'pro', stage: 'active',
onboardedAt: '2026-03-02T09:15:00.1234567Z', enrollments: 40, enrollmentsPrevious: 30, trendPercent: 33,
succeeded: 36, failed: 2, successPercent: 95, activeDays: 18, dailyEnrollments: [1, 2, 3],
firstEnrollmentDay: '2026-03-03', lastActiveDay: '2026-11-05', lastCounterUpdateAt: '2026-11-06T07:00:00Z',
lifetimeEnrollments: 900, lifetimeSucceeded: 850, lifetimeEvents: 120_000, portalUsers: 4, lastPortalVisitAt: '2026-11-06T08:00:00Z',
});
// Enrolled, but no previous enrollments, no finished session and no portal figures on the wire.
const fabrikam = tenant(2, { domainName: 'fabrikam.example', stage: 'new', enrollments: 3, activeDays: 1, lastActiveDay: '2026-11-05' });
const idle = tenant(3, { stage: 'neverEnrolled', onboardedAt: '2026-11-01T10:00:00Z' });
it('carries the window, the totals with explicit nulls and the daily series', () => {
const data = response([contoso, fabrikam, idle]);
const { activeTenantsTrendPercent: _a, enrollmentsTrendPercent: _e, successPercent: _s, top10SharePercent: _t, portalUsers: _p, portalTenants: _q, ...withoutOptional } = data.totals;
const out = shapeTenantActivity({ ...data, totals: withoutOptional }, 'summary');
expect(out.view).toBe('summary');
expect(out.window).toEqual({ days: 30, start: '2026-10-07', through: '2026-11-05', previousStart: '2026-09-07', rowsFrom: '2026-05-10' });
expect(out.portalActivityAvailable).toBe(true);
expect(out.totals).toEqual({
...withoutOptional,
activeTenantsTrendPercent: null,
enrollmentsTrendPercent: null,
successPercent: null,
top10SharePercent: null,
portalUsers: null,
portalTenants: null,
});
expect(out.daily).toEqual(data.daily);
});
it('keeps the totals the backend computed', () => {
const data = response([contoso]);
expect(shapeTenantActivity(data, 'summary').totals).toEqual(data.totals);
});
it('lists the most active tenants as slim ranking rows, absent figures as null', () => {
const mostActive = shapeTenantActivity(response([contoso, fabrikam, idle]), 'summary').mostActive as List;
expect(mostActive).toMatchObject({ total: 2, truncated: false });
expect(mostActive.rows).toEqual([
{
tenantId: tenantId(1), domain: 'contoso.example', edition: 'pro', stage: 'active', enrollments: 40, previous: 30,
trendPercent: 33, successPercent: 95, activeDays: 18, lastActiveDay: '2026-11-05', portalUsers: 4,
},
{
tenantId: tenantId(2), domain: 'fabrikam.example', edition: 'community', stage: 'new', enrollments: 3, previous: 0,
trendPercent: null, successPercent: null, activeDays: 1, lastActiveDay: '2026-11-05', portalUsers: null,
},
]);
});
it('shows the head of the four lifecycle lists and points to view=lifecycle', () => {
const out = shapeTenantActivity(response([contoso, fabrikam, idle]), 'summary');
const stages = stagesOf(out);
expect(Object.keys(stages)).toEqual([...LIFECYCLE_LISTS]);
expect(stages.new).toEqual({ total: 1, truncated: false, rows: [{ tenantId: tenantId(2), domain: 'fabrikam.example', edition: 'community', onboarded: null, firstEnrollmentDay: null, enrollments: 3 }] });
expect(stages.neverEnrolled.rows.map((r) => r.onboarded)).toEqual(['2026-11-01']);
expect(stages.declining).toEqual({ total: 0, truncated: false, rows: [] });
expect(out.note).toMatch(/view=lifecycle .* stage=/);
});
});
// ── Lifecycle rows ────────────────────────────────────────────────────────────
describe('shapeTenantActivity — lifecycle rows', () => {
const full: Partial<TenantActivityTenantItem> = {
edition: 'pro-trial', companyName: 'Contoso Ltd', onboardedAt: '2026-08-20T13:45:10.1234567Z', enrollments: 6,
enrollmentsPrevious: 20, trendPercent: -70, succeeded: 5, failed: 1, successPercent: 83, activeDays: 4,
dailyEnrollments: [6], firstEnrollmentDay: '2026-10-12', lastActiveDay: '2026-11-04', lastCounterUpdateAt: '2026-11-04T18:00:00Z',
lifetimeEnrollments: 75, lifetimeSucceeded: 70, lifetimeEvents: 9_000, portalUsers: 3, lastPortalVisitAt: '2026-11-02T07:30:00.5Z',
};
const base = { tenantId: tenantId(1), domain: 'tenant1.contoso.example', edition: 'pro-trial' };
const rowOf = (stage: TenantActivityStage, over: Partial<TenantActivityTenantItem> = full) =>
stagesOf(shapeTenantActivity(response([tenant(1, { ...over, stage })]), 'lifecycle', stage))[stage].rows[0];
it('carries id, domain and edition plus the figures of its stage only', () => {
expect(rowOf('new')).toEqual({ ...base, onboarded: '2026-08-20', firstEnrollmentDay: '2026-10-12', enrollments: 6 });
expect(rowOf('declining')).toEqual({ ...base, previous: 20, enrollments: 6, trendPercent: -70, lastActiveDay: '2026-11-04' });
expect(rowOf('dormant')).toEqual({ ...base, lifetimeEnrollments: 75, lastActiveDay: '2026-11-04' });
expect(rowOf('neverEnrolled')).toEqual({ ...base, onboarded: '2026-08-20', lastPortalVisit: '2026-11-02' });
expect(rowOf('active')).toEqual({ ...base, enrollments: 6, lastActiveDay: '2026-11-04' });
expect(rowOf('quiet')).toEqual({ ...base, enrollments: 6, lastActiveDay: '2026-11-04' });
});
it('turns absent figures into null', () => {
const bare = { edition: 'pro-trial' };
expect(rowOf('new', bare)).toEqual({ ...base, onboarded: null, firstEnrollmentDay: null, enrollments: 0 });
expect(rowOf('declining', bare)).toEqual({ ...base, previous: 0, enrollments: 0, trendPercent: null, lastActiveDay: null });
expect(rowOf('dormant', bare)).toEqual({ ...base, lifetimeEnrollments: 0, lastActiveDay: null });
expect(rowOf('neverEnrolled', bare)).toEqual({ ...base, onboarded: null, lastPortalVisit: null });
});
it('without the sign-in history the portal figures are null, never 0', () => {
const data = response([tenant(1, { stage: 'active', enrollments: 5 }), tenant(2, { stage: 'neverEnrolled' })], { portalActivityAvailable: false });
const summary = shapeTenantActivity(data, 'summary');
expect(summary.portalActivityAvailable).toBe(false);
expect((summary.mostActive as List).rows[0].portalUsers).toBeNull();
expect(stagesOf(summary).neverEnrolled.rows[0].lastPortalVisit).toBeNull();
expect((shapeTenantActivity(data, 'ranking').tenants as List).rows[0].portalUsers).toBeNull();
});
it('view=lifecycle without stage shows the four lists; with stage only that one', () => {
const data = response([tenant(1, { stage: 'quiet' }), tenant(2, { stage: 'dormant' })]);
const four = shapeTenantActivity(data, 'lifecycle');
expect(Object.keys(stagesOf(four))).toEqual([...LIFECYCLE_LISTS]);
expect(four.note).toMatch(/active and quiet/);
expect(four).not.toHaveProperty('totals');
const quiet = shapeTenantActivity(data, 'lifecycle', 'quiet');
expect(Object.keys(stagesOf(quiet))).toEqual(['quiet']);
expect(ids(stagesOf(quiet).quiet)).toEqual([tenantId(1)]);
});
});
// ── List order ────────────────────────────────────────────────────────────────
describe('shapeTenantActivity — list order', () => {
const order = (stage: TenantActivityStage, tenants: TenantActivityTenantItem[]) =>
ids(stagesOf(shapeTenantActivity(response(tenants), 'lifecycle', stage))[stage]);
it('new: the latest first day first; no first day (sessions after the last daily row) before all', () => {
expect(order('new', [
tenant(1, { stage: 'new', firstEnrollmentDay: '2026-10-10' }),
tenant(2, { stage: 'new' }),
tenant(3, { stage: 'new', firstEnrollmentDay: '2026-11-01' }),
tenant(4, { stage: 'new', firstEnrollmentDay: '2026-10-10' }),
])).toEqual([tenantId(2), tenantId(3), tenantId(1), tenantId(4)]);
});
it('declining: the largest drop against the previous window first', () => {
expect(order('declining', [
tenant(1, { stage: 'declining', enrollmentsPrevious: 10, enrollments: 5 }),
tenant(2, { stage: 'declining', enrollmentsPrevious: 20, enrollments: 2 }),
tenant(3, { stage: 'declining', enrollmentsPrevious: 8, enrollments: 4 }),
tenant(4, { stage: 'declining', enrollmentsPrevious: 12, enrollments: 7 }),
])).toEqual([tenantId(2), tenantId(1), tenantId(4), tenantId(3)]);
});
it('dormant: the most enrollments since signup first', () => {
expect(order('dormant', [
tenant(1, { stage: 'dormant', lifetimeEnrollments: 40 }),
tenant(2, { stage: 'dormant', lifetimeEnrollments: 300 }),
tenant(3, { stage: 'dormant', lifetimeEnrollments: 40 }),
tenant(4, { stage: 'dormant', lifetimeEnrollments: 7 }),
])).toEqual([tenantId(2), tenantId(1), tenantId(3), tenantId(4)]);
});
it('neverEnrolled: the newest signup first, by instant; a row without a signup time last', () => {
expect(order('neverEnrolled', [
tenant(1, { stage: 'neverEnrolled' }),
tenant(2, { stage: 'neverEnrolled', onboardedAt: '2026-10-01T10:00:00Z' }),
tenant(3, { stage: 'neverEnrolled', onboardedAt: '2026-10-01T10:00:00.5000000Z' }),
tenant(4, { stage: 'neverEnrolled', onboardedAt: '2026-11-02T08:00:00Z' }),
tenant(5, { stage: 'neverEnrolled', onboardedAt: '2026-10-01T10:00:00Z' }),
])).toEqual([tenantId(4), tenantId(3), tenantId(2), tenantId(5), tenantId(1)]);
});
it('active and quiet keep the backend order; the input is not reordered', () => {
const tenants = [
tenant(1, { stage: 'active', enrollments: 2, lifetimeEnrollments: 900 }),
tenant(2, { stage: 'active', enrollments: 9, lifetimeEnrollments: 10 }),
tenant(3, { stage: 'quiet', lastActiveDay: '2026-11-06' }),
tenant(4, { stage: 'quiet', lastActiveDay: '2026-10-01' }),
];
expect(order('active', tenants)).toEqual([tenantId(1), tenantId(2)]);
expect(order('quiet', tenants)).toEqual([tenantId(3), tenantId(4)]);
expect(tenants.map((t) => t.tenantId)).toEqual([1, 2, 3, 4].map(tenantId));
});
});
// ── Cuts ──────────────────────────────────────────────────────────────────────
describe('shapeTenantActivity — every list is cut to its cap with its full length', () => {
it('the ranking: tenants with enrollments only, in the backend order, 15 in the summary and 300 in view=ranking', () => {
const enrolled = Array.from({ length: 320 }, (_, i) => tenant(i, { enrollments: 400 - i }));
const data = response([...enrolled, tenant(900, { stage: 'neverEnrolled' })]);
const mostActive = shapeTenantActivity(data, 'summary').mostActive as List;
expect(mostActive).toMatchObject({ total: 320, truncated: true });
expect(mostActive.rows).toHaveLength(TENANT_ACTIVITY_CAPS.mostActive);
expect(TENANT_ACTIVITY_CAPS.mostActive).toBe(15);
const ranking = shapeTenantActivity(data, 'ranking').tenants as List;
expect(ranking).toMatchObject({ total: 320, truncated: true });
expect(ranking.rows).toHaveLength(300);
expect(ids(ranking)).toEqual(enrolled.slice(0, 300).map((t) => t.tenantId));
});
it('a lifecycle list: 10 in the summary, 100 in view=lifecycle, 500 with stage', () => {
const data = response(Array.from({ length: 520 }, (_, i) => tenant(i, { stage: 'neverEnrolled' })));
const head = stagesOf(shapeTenantActivity(data, 'summary')).neverEnrolled;
expect(head).toMatchObject({ total: 520, truncated: true });
expect(head.rows).toHaveLength(10);
const lifecycle = stagesOf(shapeTenantActivity(data, 'lifecycle')).neverEnrolled;
expect(lifecycle).toMatchObject({ total: 520, truncated: true });
expect(lifecycle.rows).toHaveLength(100);
const one = stagesOf(shapeTenantActivity(data, 'lifecycle', 'neverEnrolled')).neverEnrolled;
expect(one).toMatchObject({ total: 520, truncated: true });
expect(one.rows).toHaveLength(500);
});
it('a list under its cap is whole and not truncated', () => {
const data = response(Array.from({ length: 100 }, (_, i) => tenant(i, { stage: 'dormant' })));
expect(stagesOf(shapeTenantActivity(data, 'lifecycle')).dormant).toMatchObject({ total: 100, truncated: false });
expect(stagesOf(shapeTenantActivity(data, 'lifecycle')).dormant.rows).toHaveLength(100);
expect(stagesOf(shapeTenantActivity(data, 'lifecycle', 'dormant')).dormant).toMatchObject({ total: 100, truncated: false });
});
});
// ── Size ──────────────────────────────────────────────────────────────────────
/** A realistic large platform: 1,000 signups, a 90-day window, full wire rows (about 500 characters, 950 with the daily series). */
function platform(): GetTenantActivityResponse {
const mix: ReadonlyArray<readonly [TenantActivityStage, number]> = [
['neverEnrolled', 600], ['dormant', 150], ['new', 50], ['declining', 50], ['active', 120], ['quiet', 30],
];
const editions = ['community', 'pro', 'pro-trial', 'pro-msp'];
const tenants: TenantActivityTenantItem[] = [];
let i = 0;
for (const [stage, count] of mix) {
for (let k = 0; k < count; k++, i++) {
const enrollments = stage === 'active' ? 2_000 + i : stage === 'new' ? 40 + k : stage === 'declining' ? 9 : 0;
const enrolledEver = stage !== 'neverEnrolled';
tenants.push({
tenantId: `${(i % 16).toString(16).repeat(8)}-1234-4abc-9def-${String(i).padStart(12, '0')}`,
domainName: `subsidiary-${String(i).padStart(4, '0')}-manufacturing.contoso.example`,
companyName: `Contoso Manufacturing Subsidiary ${i} International GmbH`,
edition: editions[i % editions.length],
stage,
onboardedAt: '2026-04-17T13:45:10.1234567Z',
enrollments,
enrollmentsPrevious: stage === 'declining' ? 1_250 : enrollments,
...(enrolledEver ? { trendPercent: -47 } : {}),
succeeded: Math.floor(enrollments * 0.9),
failed: Math.floor(enrollments * 0.05),
...(enrollments > 0 ? { successPercent: 94, dailyEnrollments: Array.from({ length: 90 }, (_, d) => 10 + ((d * 7 + i) % 90)) } : {}),
activeDays: enrollments > 0 ? 88 : 0,
...(enrolledEver ? { firstEnrollmentDay: '2026-05-21', lastActiveDay: '2026-11-05', lastCounterUpdateAt: '2026-11-06T07:12:45.1234567Z' } : {}),
lifetimeEnrollments: enrolledEver ? 123_456 : 0,
lifetimeSucceeded: enrolledEver ? 112_233 : 0,
lifetimeEvents: enrolledEver ? 45_678_901 : 0,
portalUsers: 12,
lastPortalVisitAt: '2026-11-06T08:30:00.1234567Z',
});
}
}
const daily = Array.from({ length: 90 }, (_, d) => ({
day: new Date(Date.UTC(2026, 7, 8 + d)).toISOString().slice(0, 10), enrollments: 1_234, succeeded: 1_100, failed: 80, activeTenants: 150,
}));
return response(tenants, { windowDays: 90, windowStart: '2026-08-08', previousWindowStart: '2026-05-10', daily });
}
describe('shapeTenantActivity — size', () => {
it('every view and every stage of 1,000 signups stays under the response cap of the tool', () => {
const data = platform();
expect(data.tenants).toHaveLength(1_000);
const fullRow = JSON.stringify(data.tenants.find((t) => t.stage === 'active')).length;
expect(fullRow).toBeGreaterThan(500); // a realistic wire row, so the cut views have real work to do
const calls: Array<[TenantActivityView, TenantActivityStage?]> = [
...TENANT_ACTIVITY_VIEWS.map((view): [TenantActivityView] => [view]),
...TENANT_ACTIVITY_STAGES.map((stage): [TenantActivityView, TenantActivityStage] => ['lifecycle', stage]),
];
const oversized = calls
.map(([view, stage]) => ({ call: `${view}${stage ? `+${stage}` : ''}`, chars: JSON.stringify(shapeTenantActivity(data, view, stage)).length }))
.filter((s) => s.chars >= MAX_RESULT_SIZE_CHARS.adminStream);
expect(oversized).toEqual([]);
// The fixture is big enough to hit the caps it guards.
expect(stagesOf(shapeTenantActivity(data, 'lifecycle', 'neverEnrolled')).neverEnrolled).toMatchObject({ total: 600, truncated: true });
expect(stagesOf(shapeTenantActivity(data, 'lifecycle')).dormant).toMatchObject({ total: 150, truncated: true });
});
});
// ── The tool ──────────────────────────────────────────────────────────────────
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
interface Registered { def: { inputSchema: Record<string, any> }; handler: Handler }
function registered(ga: boolean, strictGa: boolean, delegated = false): Record<string, Registered> {
const tools: Record<string, Registered> = {};
const fake = { registerTool: (name: string, def: Registered['def'], handler: Handler) => { tools[name] = { def, handler }; } };
registerAdminTools(fake as never, ga, strictGa, delegated);
return tools;
}
describe('get_tenant_activity — registration', () => {
it('is listed for Global Admins and Global Readers only', () => {
expect(registered(true, true)).toHaveProperty('get_tenant_activity');
expect(registered(true, false)).toHaveProperty('get_tenant_activity');
expect(registered(false, false)).not.toHaveProperty('get_tenant_activity');
expect(registered(false, false, true)).not.toHaveProperty('get_tenant_activity');
});
it('takes the stages from the generated vocabulary and the views from the shaper', () => {
const { inputSchema } = registered(true, false).get_tenant_activity.def;
expect(inputSchema.stage.unwrap().options).toEqual([...TENANT_ACTIVITY_STAGES]);
expect(inputSchema.view.parse(undefined)).toBe('summary');
for (const view of TENANT_ACTIVITY_VIEWS) expect(inputSchema.view.parse(view)).toBe(view);
expect(inputSchema.days.safeParse(91).success).toBe(false);
});
});
describe('get_tenant_activity — request', () => {
beforeEach(() => apiFetchMock.mockReset());
const handler = () => registered(true, true).get_tenant_activity.handler;
it('sends days to the route and keeps view and stage in the MCP server', async () => {
apiFetchMock.mockResolvedValueOnce(response([tenant(1, { stage: 'dormant', lifetimeEnrollments: 12 })]));
const result = await handler()({ days: 7, view: 'lifecycle', stage: 'dormant' });
expect(result.isError).toBeUndefined();
expect(apiFetchMock.mock.calls[0][0]).toBe('/api/global/metrics/tenant-activity?days=7');
const out = JSON.parse(result.content[0].text);
expect(out.view).toBe('lifecycle');
expect(Object.keys(out.stages)).toEqual(['dormant']);
});
it('leaves an unset window to the backend default', async () => {
apiFetchMock.mockResolvedValueOnce(response([]));
const result = await handler()({ view: 'summary' });
expect(apiFetchMock.mock.calls[0][0]).toBe('/api/global/metrics/tenant-activity');
expect(JSON.parse(result.content[0].text).view).toBe('summary');
});
it('refuses stage with another view than lifecycle, before any request', async () => {
const result = await handler()({ view: 'ranking', stage: 'dormant' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/stage applies to view=lifecycle only/);
expect(apiFetchMock).not.toHaveBeenCalled();
});
});
@@ -146,6 +146,8 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_session_report_download',
'get_session_summary',
'get_software_inventory',
// Platform scope (GA + Global Reader): GlobalReadOrAdmin endpoint, usage per signed-up customer tenant.
'get_tenant_activity',
'get_tenant_config',
'get_tenant_config_schema',
// Available to every role: backed by MemberRead endpoints (tenant) with role-aware
@@ -232,6 +234,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_platform_metrics',
'get_rule_submission',
'get_session_report_download',
'get_tenant_activity',
'get_tenant_config',
'get_tenant_config_schema',
'get_verdict_calibration',
@@ -2527,6 +2527,26 @@ export interface GetSessionTimeAttributionResponse {
breakdown?: SessionTimeBreakdown;
}
/** Response of GET global/metrics/tenant-activity: how every signed-up tenant used the product in the last N full UTC days, against the N days before. Built from the daily usage rows (written once per day at the first maintenance run after midnight UTC), the per-tenant counters and the portal sign-in history. Operator-only (Global Admin and Global Reader). */
export interface GetTenantActivityResponse {
windowDays: number;
/** Inclusive window start ("yyyy-MM-dd", UTC). */
windowStart: string;
/** Inclusive window end ("yyyy-MM-dd", UTC): the last day the daily usage rows cover — yesterday, or the day before while yesterday is not aggregated yet. Today is never in the window. */
usageDaysThrough: string;
/** Inclusive start of the previous window of the same length ("yyyy-MM-dd"). */
previousWindowStart: string;
/** First day the daily usage rows reach ("yyyy-MM-dd", 180 days back); a first day with sessions before it is unknown. */
rowsFrom: string;
/** False when the portal sign-in history could not be read; every portal figure is then absent. */
portalActivityAvailable: boolean;
totals: TenantActivityTotals;
/** One entry per window day, oldest first, days without enrollments included. */
daily: TenantActivityDailyItem[];
/** Every signed-up tenant: most enrollments in the window first, then most enrollments since signup. */
tenants: TenantActivityTenantItem[];
}
/** Response of GET config/fields-schema: the machine-readable tenant-config field schema for the MCP write surface. */
export interface GetTenantConfigFieldsSchemaResponse {
count: number;
@@ -5524,6 +5544,106 @@ export interface TemplateVariable {
validation?: string;
}
/** One UTC day of the window, summed over the signed-up tenants. */
export interface TenantActivityDailyItem {
/** UTC day ("yyyy-MM-dd"). */
day: string;
enrollments: number;
/** Succeeded when the day was aggregated. */
succeeded: number;
/** Failed when the day was aggregated; the rest was still running or ended incomplete. */
failed: number;
/** Tenants with enrollments that day. */
activeTenants: number;
}
/** Tenants per lifecycle stage (TenantActivityStages). */
export interface TenantActivityStageCounts {
neverEnrolled: number;
dormant: number;
new: number;
declining: number;
active: number;
quiet: number;
}
/** One signed-up tenant. */
export interface TenantActivityTenantItem {
tenantId: string;
domainName: string;
companyName?: string;
/** Effective edition (TenantEditionNames). */
edition: string;
/** Lifecycle stage (TenantActivityStages). */
stage: string;
/** When the configuration row was created (the signup); absent on rows without the field. */
onboardedAt?: string;
/** Enrollment sessions started in the window. */
enrollments: number;
/** Enrollment sessions started in the previous window. */
enrollmentsPrevious: number;
/** Change against the previous window, whole percent; absent when the previous window had none. */
trendPercent?: number;
/** Of the window's enrollments, Succeeded when their day was aggregated. */
succeeded: number;
/** Of the window's enrollments, Failed when their day was aggregated. */
failed: number;
/** Succeeded / (Succeeded + Failed), whole percent; absent without a finished session. */
successPercent?: number;
/** Window days with enrollments. */
activeDays: number;
/** Enrollments per window day, oldest first; absent without enrollments in the window. */
dailyEnrollments?: number[];
/** First UTC day with sessions since signup ("yyyy-MM-dd"); absent when unknown — a signup before RowsFrom, or first sessions that only the counters have seen yet. */
firstEnrollmentDay?: string;
/** Latest UTC day with sessions ("yyyy-MM-dd"; a day after UsageDaysThrough when only the counters moved since); absent without any. */
lastActiveDay?: string;
/** When the tenant's counters last moved: an agent registration, a first success or an ingest batch — rarely a maintenance repair of the counter floors. Absent without counters. */
lastCounterUpdateAt?: string;
/** Enrollment sessions since signup (retention-independent counter). */
lifetimeEnrollments: number;
/** Sessions since signup that reached Succeeded. */
lifetimeSucceeded: number;
/** Events stored since signup. */
lifetimeEvents: number;
/** People with a portal visit in the window (counted in the home tenant of their token); absent without the sign-in history. */
portalUsers?: number;
/** Latest portal visit in the sign-in history (kept 90 days); absent without one or without the history. */
lastPortalVisitAt?: string;
}
/** Platform totals of the window; the tenant rows add up to them. */
export interface TenantActivityTotals {
/** Configuration rows with a domain: the signups that exist today. */
signedUpTenants: number;
/** Tenants with enrollments in the window. */
activeTenants: number;
/** Tenants with enrollments in the previous window. */
activeTenantsPrevious: number;
/** Change of the active tenants against the previous window, whole percent; absent when the previous window had none. */
activeTenantsTrendPercent?: number;
/** Enrollment sessions started in the window. */
enrollments: number;
/** Enrollment sessions started in the previous window. */
enrollmentsPrevious: number;
/** Change of the enrollments against the previous window, whole percent; absent when the previous window had none. */
enrollmentsTrendPercent?: number;
/** Of the window's enrollments, Succeeded when their day was aggregated. */
succeeded: number;
/** Of the window's enrollments, Failed when their day was aggregated. */
failed: number;
/** Succeeded / (Succeeded + Failed), whole percent; absent without a finished session. */
successPercent?: number;
/** Share of the window's enrollments that the 10 tenants with the most hold, whole percent; absent without enrollments. */
top10SharePercent?: number;
/** Tenants per lifecycle stage; every signed-up tenant is in exactly one. */
stages: TenantActivityStageCounts;
/** People with a portal visit in the window, summed over the tenants; absent without the sign-in history. */
portalUsers?: number;
/** Tenants with at least one such person; absent without the sign-in history. */
portalTenants?: number;
}
/** Response of POST tenants/{tenantId}/admins: the created tenant member. */
export interface TenantAdminCreatedResponse {
admin: TenantAdminRow;
@@ -316,6 +316,17 @@ export const API_ERROR_CODES = [
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
/** Lifecycle stages of the tenant activity, in their test order (C# TenantActivityStages). */
export const TENANT_ACTIVITY_STAGES = [
"neverEnrolled",
"dormant",
"new",
"declining",
"active",
"quiet",
] as const;
export type TenantActivityStage = (typeof TENANT_ACTIVITY_STAGES)[number];
/** Evidence keys a matched analyze-rule condition writes, per condition source — what a {{signal.key}} token can resolve (C# RuleEvidenceKeys). */
export const ANALYZE_RULE_EVIDENCE_KEYS: Readonly<Record<string, readonly string[]>> = {
"event_type": ["eventId","sequence","timestamp","eventType","field","value","count","appId","appName","errorPatternId","errorCode","exitCode","status","timeoutHours"],
@@ -0,0 +1,192 @@
/**
* LLM-facing shaping of get_tenant_activity (GET /api/global/metrics/tenant-activity): how every signed-up customer
* tenant used the product in the last N full UTC days, against the N days before.
*
* The backend computes every figure (stages, trend and success percentages, totals) once for the GA page Tenant
* Activity and this tool. This layer only picks the fields a view shows, orders each lifecycle list, cuts every list to
* a fixed row cap with its full length stated, and turns absent wire values into explicit nulls (the wire omits them,
* which reads as "not applicable").
*
* Rows are slim on purpose: a full tenant row is about 500 characters (950 with a 90-day daily series), so the lists in
* full would pass the adminStream response cap, and the route has no paging to fall back on. A ranking row carries the
* figures of the ranking columns; a lifecycle row carries the tenant's id, domain and edition plus the figures of its
* stage.
*/
import type { GetTenantActivityResponse, TenantActivityTenantItem } from './generated/wire-types.generated.js';
import type { TenantActivityStage } from './generated/wire-vocabularies.generated.js';
export const TENANT_ACTIVITY_VIEWS = ['summary', 'ranking', 'lifecycle'] as const;
export type TenantActivityView = (typeof TENANT_ACTIVITY_VIEWS)[number];
/** The row cap of every list a view shows; each list states its full length next to the rows it keeps. */
export const TENANT_ACTIVITY_CAPS = {
/** summary: the most active tenants, the head of view=ranking. */
mostActive: 15,
/** summary: the head of each lifecycle list. */
summaryStage: 10,
/** view=ranking: the tenants with enrollments in the window. */
ranking: 300,
/** view=lifecycle without stage: each lifecycle list. */
lifecycleStage: 100,
/** view=lifecycle with stage: that one stage. */
oneStage: 500,
} as const;
/** The lifecycle lists of the summary and of view=lifecycle without stage; active and quiet are listed by stage only. */
export const LIFECYCLE_LISTS = ['new', 'declining', 'dormant', 'neverEnrolled'] as const satisfies readonly TenantActivityStage[];
type Tenant = TenantActivityTenantItem;
/** A list cut to its cap: the full length, whether rows were left out, and the rows kept. */
interface CutList<R> {
total: number;
truncated: boolean;
rows: R[];
}
function cut<R>(tenants: readonly Tenant[], cap: number, row: (t: Tenant) => R): CutList<R> {
return { total: tenants.length, truncated: tenants.length > cap, rows: tenants.slice(0, cap).map(row) };
}
/** The UTC day of a wire timestamp: the backend writes UTC ISO 8601, so the first ten characters are the day. */
const utcDay = (iso: string | undefined): string | null => (iso === undefined ? null : iso.slice(0, 10));
/** A wire day or timestamp in milliseconds; `missing` when absent or unreadable. */
function instant(iso: string | undefined, missing: number): number {
const ms = iso === undefined ? Number.NaN : Date.parse(iso);
return Number.isNaN(ms) ? missing : ms;
}
/** Descending by key; equal keys keep the backend order (Array.prototype.sort is stable). */
const descending = (key: (t: Tenant) => number) => (a: Tenant, b: Tenant): number => {
const ka = key(a);
const kb = key(b);
return ka < kb ? 1 : ka > kb ? -1 : 0;
};
/** One tenant of the ranking: the figures of the ranking columns. */
function rankingRow(t: Tenant) {
return {
tenantId: t.tenantId,
domain: t.domainName,
edition: t.edition,
stage: t.stage,
enrollments: t.enrollments,
previous: t.enrollmentsPrevious,
trendPercent: t.trendPercent ?? null,
successPercent: t.successPercent ?? null,
activeDays: t.activeDays,
lastActiveDay: t.lastActiveDay ?? null,
portalUsers: t.portalUsers ?? null,
};
}
interface StageList {
/** The figures of the stage; the row starts with the tenant's id, domain and edition. */
figures: (t: Tenant) => Record<string, string | number | null>;
/** The list order; without one, and between equal keys, the backend order stays. */
order?: (a: Tenant, b: Tenant) => number;
}
const recentActivity = (t: Tenant) => ({ enrollments: t.enrollments, lastActiveDay: t.lastActiveDay ?? null });
/** Figures and order per stage, keyed by the generated vocabulary: a stage the backend adds fails tsc here. */
const STAGE_LISTS: Record<TenantActivityStage, StageList> = {
new: {
figures: (t) => ({ onboarded: utcDay(t.onboardedAt), firstEnrollmentDay: t.firstEnrollmentDay ?? null, enrollments: t.enrollments }),
// Newest first. Without a first day the first sessions came after the last daily row: the newest of all.
order: descending((t) => instant(t.firstEnrollmentDay, Number.POSITIVE_INFINITY)),
},
declining: {
figures: (t) => ({
previous: t.enrollmentsPrevious,
enrollments: t.enrollments,
trendPercent: t.trendPercent ?? null,
lastActiveDay: t.lastActiveDay ?? null,
}),
// The largest drop against the previous window first.
order: descending((t) => t.enrollmentsPrevious - t.enrollments),
},
dormant: {
figures: (t) => ({ lifetimeEnrollments: t.lifetimeEnrollments, lastActiveDay: t.lastActiveDay ?? null }),
// The most enrollments since signup first.
order: descending((t) => t.lifetimeEnrollments),
},
neverEnrolled: {
figures: (t) => ({ onboarded: utcDay(t.onboardedAt), lastPortalVisit: utcDay(t.lastPortalVisitAt) }),
// The newest signup first; a row without a signup time last.
order: descending((t) => instant(t.onboardedAt, Number.NEGATIVE_INFINITY)),
},
active: { figures: recentActivity },
quiet: { figures: recentActivity },
};
function stageList(tenants: readonly Tenant[], stage: TenantActivityStage, cap: number) {
const { figures, order } = STAGE_LISTS[stage];
const members = tenants.filter((t) => t.stage === stage);
if (order) members.sort(order);
return cut(members, cap, (t) => ({ tenantId: t.tenantId, domain: t.domainName, edition: t.edition, ...figures(t) }));
}
function lifecycleLists(tenants: readonly Tenant[], cap: number) {
return Object.fromEntries(LIFECYCLE_LISTS.map((stage) => [stage, stageList(tenants, stage, cap)]));
}
const SUMMARY_NOTE =
`mostActive is the head of view=ranking; each stage list shows its first ${TENANT_ACTIVITY_CAPS.summaryStage} rows. ` +
`view=lifecycle lists them up to ${TENANT_ACTIVITY_CAPS.lifecycleStage} rows each, and with stage= one stage up to ` +
`${TENANT_ACTIVITY_CAPS.oneStage} rows, active and quiet included.`;
const LIFECYCLE_NOTE = `stage= lists one stage up to ${TENANT_ACTIVITY_CAPS.oneStage} rows, active and quiet included.`;
/**
* One view of the tenant activity. `stage` applies to view=lifecycle only (the tool refuses it with another view):
* that one stage's list instead of the four lifecycle lists.
*/
export function shapeTenantActivity(
data: GetTenantActivityResponse,
view: TenantActivityView,
stage?: TenantActivityStage,
): Record<string, unknown> {
const head = {
view,
window: {
days: data.windowDays,
start: data.windowStart,
through: data.usageDaysThrough,
previousStart: data.previousWindowStart,
rowsFrom: data.rowsFrom,
},
portalActivityAvailable: data.portalActivityAvailable,
};
// In the backend order: the most enrollments in the window first, then the most since signup.
const enrolled = data.tenants.filter((t) => t.enrollments > 0);
const totals = data.totals;
switch (view) {
case 'summary':
return {
...head,
totals: {
...totals,
activeTenantsTrendPercent: totals.activeTenantsTrendPercent ?? null,
enrollmentsTrendPercent: totals.enrollmentsTrendPercent ?? null,
successPercent: totals.successPercent ?? null,
top10SharePercent: totals.top10SharePercent ?? null,
portalUsers: totals.portalUsers ?? null,
portalTenants: totals.portalTenants ?? null,
},
daily: data.daily,
mostActive: cut(enrolled, TENANT_ACTIVITY_CAPS.mostActive, rankingRow),
stages: lifecycleLists(data.tenants, TENANT_ACTIVITY_CAPS.summaryStage),
note: SUMMARY_NOTE,
};
case 'ranking':
return { ...head, tenants: cut(enrolled, TENANT_ACTIVITY_CAPS.ranking, rankingRow) };
case 'lifecycle':
return stage
? { ...head, stages: { [stage]: stageList(data.tenants, stage, TENANT_ACTIVITY_CAPS.oneStage) } }
: { ...head, stages: lifecycleLists(data.tenants, TENANT_ACTIVITY_CAPS.lifecycleStage), note: LIFECYCLE_NOTE };
}
}
@@ -94,6 +94,7 @@ export function buildFleetOverview(
}
import { shapeVerdictCalibration } from '../verdict-calibration-shape.js';
import { OVERVIEW_VIEWS, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
import { TENANT_ACTIVITY_CAPS, TENANT_ACTIVITY_VIEWS, shapeTenantActivity } from '../tenant-activity-shape.js';
import type {
AgentEfficiencyMetricsResponse,
AppMetricsResponse,
@@ -110,6 +111,7 @@ import type {
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
GetSessionTimeAttributionResponse,
GetTenantActivityResponse,
GetTenantConfigFieldsSchemaResponse,
GetUnmatchedSoftwareResponse,
GrowthMetricsResponse,
@@ -159,6 +161,7 @@ import {
RULE_SUBMISSION_KINDS,
RULE_SUBMISSION_STATUSES,
SESSION_STATUSES,
TENANT_ACTIVITY_STAGES,
} from '../generated/wire-vocabularies.generated.js';
/**
@@ -1084,6 +1087,49 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
})
);
// Tool: get_tenant_activity — platform scope (GA + Global Reader). Usage per signed-up customer tenant, as on
// the GA page Tenant Activity. The shaping lives in tenant-activity-shape.ts; every figure is computed by the backend.
if (ga) server.registerTool(
'get_tenant_activity',
{
title: 'Tenant Activity',
description:
'Usage per signed-up customer tenant, as on the GA page Tenant Activity: the last N full UTC days through ' +
'window.through (yesterday, or the day before until yesterday is aggregated) against the N days before; one ' +
'enrollment is one session. Each tenant has one stage: neverEnrolled (no enrollment yet), dormant (enrolled, no ' +
'activity in the last 30 days), new (first sessions in the window), declining (at least 5 enrollments in the ' +
'previous window, at most half as many now), active (enrollments in the window) or quiet (active in the last 30 ' +
'days, not in the window). Portal users are people with a portal visit in the window, counted in the home tenant ' +
'of their token: an MSP admin counts in their own tenant, a service principal never. view=summary: totals, the ' +
'daily series, the most active tenants and the head of each lifecycle list; view=ranking: the tenants with ' +
'enrollments, most first; view=lifecycle: the new, declining, dormant and neverEnrolled lists, or one stage with ' +
'stage=. Lists are cut to a fixed number of rows; total gives the full length. null = not computable, or not ' +
'readable for portal figures.',
inputSchema: {
days: z.coerce.number().int().min(1).max(90).optional()
.describe(daysDescription(30, 90)),
view: z.enum(TENANT_ACTIVITY_VIEWS).optional().default('summary')
.describe('Default summary.'),
stage: z.enum(TENANT_ACTIVITY_STAGES).optional()
.describe(`view=lifecycle only: that one stage, up to ${TENANT_ACTIVITY_CAPS.oneStage} rows (active and quiet too).`),
},
annotations: READ_ONLY,
},
async (args) => withToolTelemetry('get_tenant_activity', args, async () => {
try {
const { days, view, stage } = args;
if (stage && view !== 'lifecycle') {
return toolError('get_tenant_activity', args, new Error(
`stage applies to view=lifecycle only; re-send with view=lifecycle and stage=${stage}.`));
}
const data = await apiFetch<GetTenantActivityResponse>(`/api/global/metrics/tenant-activity${buildQuery({ days })}`);
return toolResultText(shapeTenantActivity(data, view, stage), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
return toolError('get_tenant_activity', args, error);
}
})
);
// Tool 18: list_session_reports — Global Admin only; not registered for normal users.
if (ga) server.registerTool(
'list_session_reports',
@@ -104,6 +104,12 @@ namespace AutopilotMonitor.Shared.DataAccess
/// validated tokens, which is what makes them usable as an identity-binding source.
/// </summary>
Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn);
/// <summary>
/// Every portal visit of a person since <paramref name="sinceUtc"/>, from the UserActivity sign-in history
/// (one cross-partition scan filtered on LoginAt; the table keeps 90 days). Service principals and rows
/// without any identity are left out. Not fail-soft: the caller decides how to report a missing history.
/// </summary>
Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc);
// --- Live Presence (one row per user, upserted) ---
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
@@ -0,0 +1,18 @@
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// A tenant's effective edition as the portal's plan facet names it. The display precedence is Pro conferred
/// by a managing (MSP) tenant, then the stored Pro tier, then a running trial, else Community — the order of
/// the backend's edition resolution and of the portal's <c>effectiveEdition</c>. The web derives its
/// <c>PlanEdition</c> type from this list (shared manifest), so the two cannot name different values.
/// </summary>
public static class TenantEditionNames
{
public const string Community = "community";
public const string Pro = "pro";
public const string ProTrial = "pro-trial";
public const string ProMsp = "pro-msp";
public static readonly string[] All = { Community, Pro, ProTrial, ProMsp };
}
}
@@ -0,0 +1,21 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// One portal visit of a person, read from the UserActivity sign-in history (one row per auth/me call). The
/// reader leaves out service principals (<c>app:</c> keys) and rows without any identity, so every entry is a
/// person.
/// </summary>
public class PortalVisit
{
/// <summary>Tenant of the token, lower-cased: a delegated (MSP) admin counts in their home tenant.</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>The person: the Entra object id, else the lower-cased UPN.</summary>
public string PersonKey { get; set; } = string.Empty;
/// <summary>When the visit was recorded (UTC).</summary>
public DateTime At { get; set; }
}
}
@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Lifecycle stage of a signed-up tenant in the tenant activity figures. Every tenant has exactly one: the
/// stages are tested in the order of <see cref="All"/> and the first that applies wins.
/// </summary>
public static class TenantActivityStages
{
/// <summary>No enrollment on record (the tenant's lifetime counter is zero).</summary>
public const string NeverEnrolled = "neverEnrolled";
/// <summary>Enrolled, but no activity in the last 30 days, today included — the dormant rule of the growth figures.</summary>
public const string Dormant = "dormant";
/// <summary>
/// The first day with sessions lies in the window, or the first sessions came after the last daily row.
/// Only decided for signups inside the 180 days the daily rows reach.
/// </summary>
public const string New = "new";
/// <summary>At least 5 enrollments in the previous window and at most half as many in this one.</summary>
public const string Declining = "declining";
/// <summary>Enrollments in the window.</summary>
public const string Active = "active";
/// <summary>Enrolled and active in the last 30 days, but no enrollment in the window.</summary>
public const string Quiet = "quiet";
public static readonly string[] All = { NeverEnrolled, Dormant, New, Declining, Active, Quiet };
}
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET global/metrics/tenant-activity</c>: how every signed-up tenant used the product in the
/// last N full UTC days, against the N days before. Built from the daily usage rows (written once per day at
/// the first maintenance run after midnight UTC), the per-tenant counters and the portal sign-in history.
/// Operator-only (Global Admin and Global Reader).
/// </summary>
public class GetTenantActivityResponse : IApiResponse
{
public int WindowDays { get; set; }
/// <summary>Inclusive window start ("yyyy-MM-dd", UTC).</summary>
public string WindowStart { get; set; } = string.Empty;
/// <summary>
/// Inclusive window end ("yyyy-MM-dd", UTC): the last day the daily usage rows cover — yesterday, or the
/// day before while yesterday is not aggregated yet. Today is never in the window.
/// </summary>
public string UsageDaysThrough { get; set; } = string.Empty;
/// <summary>Inclusive start of the previous window of the same length ("yyyy-MM-dd").</summary>
public string PreviousWindowStart { get; set; } = string.Empty;
/// <summary>First day the daily usage rows reach ("yyyy-MM-dd", 180 days back); a first day with sessions before it is unknown.</summary>
public string RowsFrom { get; set; } = string.Empty;
/// <summary>False when the portal sign-in history could not be read; every portal figure is then absent.</summary>
public bool PortalActivityAvailable { get; set; }
public TenantActivityTotals Totals { get; set; } = default!;
/// <summary>One entry per window day, oldest first, days without enrollments included.</summary>
public IReadOnlyList<TenantActivityDailyItem> Daily { get; set; } = default!;
/// <summary>Every signed-up tenant: most enrollments in the window first, then most enrollments since signup.</summary>
public IReadOnlyList<TenantActivityTenantItem> Tenants { get; set; } = default!;
}
/// <summary>Platform totals of the window; the tenant rows add up to them.</summary>
public class TenantActivityTotals
{
/// <summary>Configuration rows with a domain: the signups that exist today.</summary>
public int SignedUpTenants { get; set; }
/// <summary>Tenants with enrollments in the window.</summary>
public int ActiveTenants { get; set; }
/// <summary>Tenants with enrollments in the previous window.</summary>
public int ActiveTenantsPrevious { get; set; }
/// <summary>Change of the active tenants against the previous window, whole percent; absent when the previous window had none.</summary>
public int? ActiveTenantsTrendPercent { get; set; }
/// <summary>Enrollment sessions started in the window.</summary>
public int Enrollments { get; set; }
/// <summary>Enrollment sessions started in the previous window.</summary>
public int EnrollmentsPrevious { get; set; }
/// <summary>Change of the enrollments against the previous window, whole percent; absent when the previous window had none.</summary>
public int? EnrollmentsTrendPercent { get; set; }
/// <summary>Of the window's enrollments, Succeeded when their day was aggregated.</summary>
public int Succeeded { get; set; }
/// <summary>Of the window's enrollments, Failed when their day was aggregated.</summary>
public int Failed { get; set; }
/// <summary>Succeeded / (Succeeded + Failed), whole percent; absent without a finished session.</summary>
public int? SuccessPercent { get; set; }
/// <summary>Share of the window's enrollments that the 10 tenants with the most hold, whole percent; absent without enrollments.</summary>
public int? Top10SharePercent { get; set; }
/// <summary>Tenants per lifecycle stage; every signed-up tenant is in exactly one.</summary>
public TenantActivityStageCounts Stages { get; set; } = default!;
/// <summary>People with a portal visit in the window, summed over the tenants; absent without the sign-in history.</summary>
public int? PortalUsers { get; set; }
/// <summary>Tenants with at least one such person; absent without the sign-in history.</summary>
public int? PortalTenants { get; set; }
}
/// <summary>Tenants per lifecycle stage (<see cref="TenantActivityStages"/>).</summary>
public class TenantActivityStageCounts
{
public int NeverEnrolled { get; set; }
public int Dormant { get; set; }
public int New { get; set; }
public int Declining { get; set; }
public int Active { get; set; }
public int Quiet { get; set; }
}
/// <summary>One UTC day of the window, summed over the signed-up tenants.</summary>
public class TenantActivityDailyItem
{
/// <summary>UTC day ("yyyy-MM-dd").</summary>
public string Day { get; set; } = string.Empty;
public int Enrollments { get; set; }
/// <summary>Succeeded when the day was aggregated.</summary>
public int Succeeded { get; set; }
/// <summary>Failed when the day was aggregated; the rest was still running or ended incomplete.</summary>
public int Failed { get; set; }
/// <summary>Tenants with enrollments that day.</summary>
public int ActiveTenants { get; set; }
}
/// <summary>One signed-up tenant.</summary>
public class TenantActivityTenantItem
{
public string TenantId { get; set; } = string.Empty;
public string DomainName { get; set; } = string.Empty;
public string? CompanyName { get; set; }
/// <summary>Effective edition (<see cref="TenantEditionNames"/>).</summary>
public string Edition { get; set; } = string.Empty;
/// <summary>Lifecycle stage (<see cref="TenantActivityStages"/>).</summary>
public string Stage { get; set; } = string.Empty;
/// <summary>When the configuration row was created (the signup); absent on rows without the field.</summary>
public DateTime? OnboardedAt { get; set; }
/// <summary>Enrollment sessions started in the window.</summary>
public int Enrollments { get; set; }
/// <summary>Enrollment sessions started in the previous window.</summary>
public int EnrollmentsPrevious { get; set; }
/// <summary>Change against the previous window, whole percent; absent when the previous window had none.</summary>
public int? TrendPercent { get; set; }
/// <summary>Of the window's enrollments, Succeeded when their day was aggregated.</summary>
public int Succeeded { get; set; }
/// <summary>Of the window's enrollments, Failed when their day was aggregated.</summary>
public int Failed { get; set; }
/// <summary>Succeeded / (Succeeded + Failed), whole percent; absent without a finished session.</summary>
public int? SuccessPercent { get; set; }
/// <summary>Window days with enrollments.</summary>
public int ActiveDays { get; set; }
/// <summary>Enrollments per window day, oldest first; absent without enrollments in the window.</summary>
public int[]? DailyEnrollments { get; set; }
/// <summary>
/// First UTC day with sessions since signup ("yyyy-MM-dd"); absent when unknown — a signup before
/// <see cref="GetTenantActivityResponse.RowsFrom"/>, or first sessions that only the counters have seen yet.
/// </summary>
public string? FirstEnrollmentDay { get; set; }
/// <summary>Latest UTC day with sessions ("yyyy-MM-dd"; a day after UsageDaysThrough when only the counters moved since); absent without any.</summary>
public string? LastActiveDay { get; set; }
/// <summary>
/// When the tenant's counters last moved: an agent registration, a first success or an ingest batch — rarely
/// a maintenance repair of the counter floors. Absent without counters.
/// </summary>
public DateTime? LastCounterUpdateAt { get; set; }
/// <summary>Enrollment sessions since signup (retention-independent counter).</summary>
public long LifetimeEnrollments { get; set; }
/// <summary>Sessions since signup that reached Succeeded.</summary>
public long LifetimeSucceeded { get; set; }
/// <summary>Events stored since signup.</summary>
public long LifetimeEvents { get; set; }
/// <summary>People with a portal visit in the window (counted in the home tenant of their token); absent without the sign-in history.</summary>
public int? PortalUsers { get; set; }
/// <summary>Latest portal visit in the sign-in history (kept 90 days); absent without one or without the history.</summary>
public DateTime? LastPortalVisitAt { get; set; }
}
}
@@ -16,5 +16,11 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Sessions the tenant started that day.</summary>
public int SessionsTotal { get; set; }
/// <summary>Of those, Succeeded when the day was aggregated (the first maintenance run after midnight UTC).</summary>
public int SessionsSucceeded { get; set; }
/// <summary>Of those, Failed when the day was aggregated; a session still running then counts in neither.</summary>
public int SessionsFailed { get; set; }
}
}
@@ -13,8 +13,15 @@
*/
import { hasAnyDeviceValidation } from "@/lib/deviceValidation";
import { SHARED_MANIFEST } from "@/lib/generated/shared-manifests.generated";
export type PlanEdition = "community" | "pro" | "pro-trial" | "pro-msp";
/** The plan facet values — the backend's edition names (C# TenantEditionNames), so the two cannot drift. */
export type PlanEdition = (typeof SHARED_MANIFEST.tenantEditions)[number];
/** A wire edition name as a plan facet value; an unknown name (a newer backend) reads as Community. */
export function planEditionOf(name: string): PlanEdition {
return (SHARED_MANIFEST.tenantEditions as readonly string[]).includes(name) ? (name as PlanEdition) : "community";
}
export type TrialState = "active" | "expired" | "never";
export type AppValue = "primary" | "legacy" | "unknown";
@@ -6,6 +6,7 @@ import { SectionAgentMetrics } from "../sections/SectionAgentMetrics";
import { SectionCustomers } from "../sections/SectionCustomers";
import { SectionPlatformUsage } from "../sections/SectionPlatformUsage";
import { SectionMcpUsage } from "../sections/SectionMcpUsage";
import { SectionTenantActivity } from "../sections/SectionTenantActivity";
import { SectionVerdictCalibration } from "../sections/SectionVerdictCalibration";
import { SectionImePatternHealth } from "../sections/SectionImePatternHealth";
@@ -13,6 +14,7 @@ const SECTION_COMPONENTS: Record<MetricsSectionId, React.ComponentType> = {
"customers": SectionCustomers,
"platform-metrics": SectionAgentMetrics,
"usage": SectionPlatformUsage,
"tenant-activity": SectionTenantActivity,
"mcp-usage": SectionMcpUsage,
"verdict-calibration": SectionVerdictCalibration,
"ime-pattern-health": SectionImePatternHealth,
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { formatDayKey } from "../format";
const LONG: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" };
describe("formatDayKey", () => {
it("formats compact and dashed UTC day keys alike", () => {
expect(formatDayKey("20261105", LONG)).toBe(formatDayKey("2026-11-05", LONG));
expect(formatDayKey("2026-11-05", LONG)).toBe(new Date(Date.UTC(2026, 10, 5)).toLocaleDateString(undefined, { ...LONG, timeZone: "UTC" }));
});
it("never shifts a day by the viewer's time zone", () => {
expect(formatDayKey("2026-01-01", { day: "numeric", timeZone: "Pacific/Honolulu" })).toBe(
new Date(Date.UTC(2026, 0, 1)).toLocaleDateString(undefined, { day: "numeric", timeZone: "UTC" }),
);
});
it("leaves anything else unchanged", () => {
expect(formatDayKey("2026-1105")).toBe("2026-1105");
expect(formatDayKey("2026-11-05T10:00:00Z")).toBe("2026-11-05T10:00:00Z");
expect(formatDayKey("")).toBe("");
});
});
@@ -0,0 +1,13 @@
/** Compact ("yyyyMMdd", the MCP usage counters) and dashed ("yyyy-MM-dd", growth and tenant activity) UTC day keys. */
const COMPACT_DAY = /^(\d{4})(\d{2})(\d{2})$/;
const DASHED_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
/**
* A UTC calendar day key in the viewer's locale, never shifted by the viewer's time zone. Takes both key forms the
* GA metrics routes send; anything else comes back unchanged.
*/
export function formatDayKey(key: string, options: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" }): string {
const match = COMPACT_DAY.exec(key) ?? DASHED_DAY.exec(key);
if (!match) return key;
return new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`).toLocaleDateString(undefined, { ...options, timeZone: "UTC" });
}
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import type { SortDir } from "./mcpUsageLogic";
export type SortDir = "asc" | "desc";
/** A sortable column set: a click on the active column flips the direction, another column starts at its default. */
export function useSortState<K extends string>(initial: K, defaultDir: Record<K, SortDir>) {
@@ -2,6 +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: "tenant-activity", label: "Tenant Activity", description: "Which tenants enroll how much, against the window before, and who is new, declining, dormant or never enrolled" },
{ 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" },
@@ -14,6 +14,7 @@ import { useWindowDays } from "@/hooks/useWindowDays";
import { PLATFORM_MAX_WINDOW_DAYS, WINDOW_PRESET_OPTIONS } from "@/lib/timeWindow";
import type { TenantConfiguration } from "@/lib/generated/wire-types.generated";
import { editionLabel, effectiveEdition, facetOption } from "../../components/tenantFilters";
import { formatDayKey } from "../lib/format";
import { customerGrowth, type GrowthBucket } from "./customerGrowthLogic";
import { Stat } from "./Stat";
@@ -34,15 +35,10 @@ const SHORT_DAY: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" }
const FULL_DAY: Intl.DateTimeFormatOptions = { weekday: "short", day: "numeric", month: "short", year: "numeric" };
const RANGE_DAY: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" };
/** A UTC calendar day (yyyy-MM-dd) in the viewer's locale, never shifted by the viewer's time zone. */
function formatDay(key: string, options: Intl.DateTimeFormatOptions): string {
return new Date(`${key}T00:00:00Z`).toLocaleDateString(undefined, { ...options, timeZone: "UTC" });
}
function bucketTitle(bucket: { startKey: string; endKey: string }, weekly: boolean): string {
return weekly
? `${formatDay(bucket.startKey, RANGE_DAY)} – ${formatDay(bucket.endKey, RANGE_DAY)}`
: formatDay(bucket.startKey, FULL_DAY);
? `${formatDayKey(bucket.startKey, RANGE_DAY)} – ${formatDayKey(bucket.endKey, RANGE_DAY)}`
: formatDayKey(bucket.startKey, FULL_DAY);
}
function signupTime(atMs: number, weekly: boolean): string {
@@ -106,7 +102,7 @@ function CustomersContent() {
count: b.count,
cumulative: b.cumulative,
}));
const formatCategory = (key: string) => formatDay(key, days <= 7 ? WEEKDAY : SHORT_DAY);
const formatCategory = (key: string) => formatDayKey(key, days <= 7 ? WEEKDAY : SHORT_DAY);
const toggleBucket = (row: Record<string, unknown>) => {
const key = row.key;
@@ -121,7 +117,7 @@ function CustomersContent() {
<h2 className="text-lg font-semibold text-gray-900">Customers</h2>
<p className="text-sm text-gray-500">
{growth
? `${formatDay(growth.startKey, RANGE_DAY)} – ${formatDay(growth.endKey, RANGE_DAY)}`
? `${formatDayKey(growth.startKey, RANGE_DAY)} – ${formatDayKey(growth.endKey, RANGE_DAY)}`
: "Tenant signups over time"}
</p>
</div>
@@ -184,7 +180,7 @@ function CustomersContent() {
<Stat
label="Best day"
value={growth.bestDay ? growth.bestDay.count.toLocaleString() : "—"}
hint={growth.bestDay ? formatDay(growth.bestDay.key, FULL_DAY) : "no signups in this window"}
hint={growth.bestDay ? formatDayKey(growth.bestDay.key, FULL_DAY) : "no signups in this window"}
/>
<Stat
label="Weekly average"
@@ -17,11 +17,11 @@ import type {
PlanTierDefinitionsResponse,
TenantConfiguration,
} from "@/lib/generated/wire-types.generated";
import { formatDayKey } from "../lib/format";
import { Stat } from "./Stat";
import {
dailyChart,
editionCounts,
formatDayKey,
MCP_USAGE_MAX_DAYS,
MCP_USAGE_VIEW_PARAM,
parseView,
@@ -0,0 +1,243 @@
"use client";
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 { 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 { GetTenantActivityResponse } from "@/lib/generated/wire-types.generated";
import { formatDayKey } from "../lib/format";
import { Stat } from "./Stat";
import { LifecycleTab } from "./tenantActivity/LifecycleTab";
import { RankingTab } from "./tenantActivity/RankingTab";
import {
changeText,
dailyChartRows,
parseView,
percentText,
TENANT_ACTIVITY_MAX_DAYS,
TENANT_ACTIVITY_VIEW_PARAM,
type TenantActivityView,
} from "./tenantActivity/tenantActivityLogic";
function ChartLoading() {
return <div className="flex h-56 items-center justify-center text-sm text-gray-400">Loading chart…</div>;
}
// recharts loads only when this section renders: SectionClient imports every metrics section statically.
const AppStackedBarChart = dynamic(() => import("@/components/charts/AppStackedBarChart"), { ssr: false, loading: ChartLoading });
const DEFAULT_WINDOW_DAYS = 30;
const LONG_DAY: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" };
const DAY_STACKS = [
{ dataKey: "succeeded", label: "Succeeded", color: chartColors.success },
{ dataKey: "failed", label: "Failed", color: chartColors.danger },
{ dataKey: "other", label: "Running or incomplete", color: chartColors.muted },
];
/**
* GA metrics: how every customer tenant uses the product (GET global/metrics/tenant-activity) — the most active
* tenants, their trend against the window before and the tenants that need a look. The window lives in ?days= (at
* most 90 full UTC days, through the last aggregated day), the tab in ?view=.
*/
export function SectionTenantActivity() {
// useSearchParams (?days=, ?view=) requires a Suspense boundary under the static export.
return (
<Suspense fallback={<div className="min-h-screen bg-gray-50" />}>
<TenantActivityContent />
</Suspense>
);
}
function TenantActivityContent() {
const { getAccessToken } = useAuth();
const searchParams = useSearchParams();
// Platform roles may ask for more; the daily rows and the window before fit 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, TENANT_ACTIVITY_MAX_DAYS);
const view = parseView(searchParams?.get(TENANT_ACTIVITY_VIEW_PARAM));
// The answer and the clock it was read at, set together so a render never mixes two fetches.
const [snapshot, setSnapshot] = useState<{ data: GetTenantActivityResponse; nowMs: number } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const progress = useFetchProgress("tenantActivity.estimateMs", 5_000);
const { begin, finish } = progress;
// An answer for an older window must never replace a newer one.
const latestRequest = useRef(0);
const load = useCallback(async () => {
const request = ++latestRequest.current;
setLoading(true);
setError(null);
begin();
let answered = false;
try {
const data = await fetchJson<GetTenantActivityResponse>(api.metrics.globalTenantActivity(days), getAccessToken);
if (request === latestRequest.current) {
setSnapshot({ data, nowMs: Date.now() });
answered = true;
}
} catch (err) {
if (request === latestRequest.current) setError(apiErrorText(err, "Failed to load tenant activity"));
} finally {
if (request === latestRequest.current) {
setLoading(false);
finish(answered);
}
}
}, [days, getAccessToken, begin, finish]);
useEffect(() => {
const run = async () => {
await load();
};
void run();
}, [load]);
const showView = useCallback((next: TenantActivityView) => {
const url = new URL(window.location.href);
if (next === "ranking") url.searchParams.delete(TENANT_ACTIVITY_VIEW_PARAM);
else url.searchParams.set(TENANT_ACTIVITY_VIEW_PARAM, next);
window.history.replaceState(null, "", url.toString());
}, []);
const chartRows = useMemo(() => (snapshot ? dailyChartRows(snapshot.data.daily) : []), [snapshot]);
const data = snapshot?.data;
const stages = data?.totals.stages;
const needLook = stages ? stages.new + stages.declining + stages.dormant + stages.neverEnrolled : 0;
const tabs: { value: TenantActivityView; label: string }[] = [
{ value: "ranking", label: "Ranking" },
{ value: "lifecycle", label: needLook > 0 ? `Lifecycle (${needLook.toLocaleString()})` : "Lifecycle" },
];
return (
<div className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">Tenant Activity</h2>
<p className="text-sm text-gray-500">
{data
? `${formatDayKey(data.windowStart, LONG_DAY)} – ${formatDayKey(data.usageDaysThrough, LONG_DAY)} · full UTC days, today not yet counted`
: "How every customer tenant uses the product"}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl options={WINDOW_PRESET_OPTIONS} value={days} onChange={setDays} />
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
>
Refresh
</button>
</div>
</div>
{error && (
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-8">
<CalculatingDialog
title="Reading tenant activity"
subtitle="Daily usage rows, the tenant counters and the portal sign-in history"
elapsedMs={progress.elapsedMs}
estimateMs={progress.estimateMs}
/>
</div>
) : snapshot && data && stages ? (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-5">
<Stat
label="Active tenants"
value={data.totals.activeTenants.toLocaleString()}
hint={`of ${data.totals.signedUpTenants.toLocaleString()} signed up · ${stages.new.toLocaleString()} new`}
/>
<Stat
label="Enrollments"
value={data.totals.enrollments.toLocaleString()}
hint={changeText(data.totals.enrollmentsTrendPercent, data.windowDays)}
/>
<Stat
label="Success rate"
value={percentText(data.totals.successPercent)}
hint={`${data.totals.failed.toLocaleString()} failed`}
/>
<Stat label="Top 10 share" value={percentText(data.totals.top10SharePercent)} hint="of the enrollments" />
<Stat
label="Portal users"
value={data.totals.portalUsers == null ? "—" : data.totals.portalUsers.toLocaleString()}
hint={data.totals.portalTenants == null ? "sign-in history unavailable" : `in ${data.totals.portalTenants.toLocaleString()} tenants`}
/>
</div>
<div className="rounded-lg bg-white p-6 shadow">
<h3 className="text-sm font-medium text-gray-500">Enrollments per day</h3>
{data.totals.enrollments === 0 ? (
<p className="py-10 text-center text-sm text-gray-500">No enrollments in this window.</p>
) : (
<div className="mt-4">
<AppStackedBarChart
data={chartRows}
xKey="day"
stacks={DAY_STACKS}
height={220}
formatXTick={(value) => formatDayKey(String(value))}
renderTooltip={(row) => <EnrollmentDayTooltip row={row} />}
/>
</div>
)}
</div>
<SegmentedControl options={tabs} value={view} onChange={showView} className="w-fit max-w-full overflow-x-auto" />
{view === "ranking" ? (
<RankingTab
tenants={data.tenants}
totalEnrollments={data.totals.enrollments}
windowStart={data.windowStart}
windowDays={data.windowDays}
portalAvailable={data.portalActivityAvailable}
nowMs={snapshot.nowMs}
/>
) : (
<LifecycleTab tenants={data.tenants} portalAvailable={data.portalActivityAvailable} nowMs={snapshot.nowMs} />
)}
</>
) : null}
</div>
);
}
const TOOLTIP_LINES: ReadonlyArray<[string, string]> = [
["Enrollments", "enrollments"],
["Succeeded", "succeeded"],
["Failed", "failed"],
["Running or incomplete", "other"],
["Active tenants", "activeTenants"],
];
function EnrollmentDayTooltip({ row }: { row: Record<string, unknown> }) {
return (
<div className="space-y-1">
<p className="font-medium">{formatDayKey(String(row.day), { weekday: "short", day: "numeric", month: "short" })}</p>
{TOOLTIP_LINES.map(([label, key]) => (
<p key={key} className="flex justify-between gap-4">
<span>{label}</span>
<span className="font-semibold tabular-nums">{Number(row[key] ?? 0).toLocaleString()}</span>
</p>
))}
</div>
);
}
@@ -12,7 +12,6 @@ import {
accountNearLimit,
dailyChart,
formatChars,
formatDayKey,
formatDuration,
formatShare,
editionCounts,
@@ -251,9 +250,4 @@ describe("formatting", () => {
expect(formatShare(0.25)).toBe("25 %");
expect(formatShare(undefined)).toBe("—");
});
it("leaves anything but a yyyyMMdd day unchanged", () => {
expect(formatDayKey("2026-11-05")).toBe("2026-11-05");
expect(formatDayKey("20261105")).not.toBe("20261105");
});
});
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import type { TenantActivityTenantItem } from "@/lib/generated/wire-types.generated";
import { planEditionOf } from "../../../components/tenantFilters";
import { formatDayKey } from "../../lib/format";
import {
agoText,
changeText,
dailyChartRows,
LIFECYCLE_STAGES,
lifecycleFigures,
lifecycleGroups,
parseView,
percentText,
rankingRows,
searchTenants,
shareText,
sortRanking,
trendText,
windowDayKeys,
} from "../tenantActivity/tenantActivityLogic";
// 2026-11-06 15:00 UTC.
const NOW = Date.UTC(2026, 10, 6, 15);
function tenant(id: string, over: Partial<TenantActivityTenantItem> = {}): TenantActivityTenantItem {
return {
tenantId: id,
domainName: `${id}.example`,
edition: "community",
stage: "active",
enrollments: 0,
enrollmentsPrevious: 0,
succeeded: 0,
failed: 0,
activeDays: 0,
lifetimeEnrollments: 0,
lifetimeSucceeded: 0,
lifetimeEvents: 0,
...over,
};
}
const ids = (rows: readonly TenantActivityTenantItem[]) => rows.map((t) => t.tenantId);
describe("view", () => {
it("opens the ranking unless the link asks for the lifecycle", () => {
expect(parseView("lifecycle")).toBe("lifecycle");
expect(parseView("ranking")).toBe("ranking");
expect(parseView("tools")).toBe("ranking");
expect(parseView(null)).toBe("ranking");
});
});
describe("ranking", () => {
const rows = [
tenant("contoso", { enrollments: 40, trendPercent: 10, successPercent: 95, activeDays: 20, portalUsers: 3, lastActiveDay: "2026-11-05" }),
tenant("fabrikam", { enrollments: 12, successPercent: 70, activeDays: 4, lastCounterUpdateAt: "2026-11-06T08:00:00.1234567Z" }),
tenant("northwind", { enrollments: 12, trendPercent: -60, activeDays: 9, portalUsers: 0, lastActiveDay: "2026-10-01" }),
tenant("quiet", { stage: "quiet", lifetimeEnrollments: 5 }),
];
it("lists the tenants with enrollments in the route's order", () => {
expect(ids(rankingRows(rows))).toEqual(["contoso", "fabrikam", "northwind"]);
});
it("sorts every column and keeps the route's order on a tie", () => {
const active = rankingRows(rows);
expect(ids(sortRanking(active, "enrollments", "desc"))).toEqual(["contoso", "fabrikam", "northwind"]);
expect(ids(sortRanking(active, "enrollments", "asc"))).toEqual(["fabrikam", "northwind", "contoso"]);
expect(ids(sortRanking(active, "activeDays", "desc"))).toEqual(["contoso", "northwind", "fabrikam"]);
expect(ids(sortRanking(active, "success", "asc"))).toEqual(["fabrikam", "contoso", "northwind"]);
// The counters' last move counts before a day with sessions.
expect(ids(sortRanking(active, "lastActive", "desc"))).toEqual(["fabrikam", "contoso", "northwind"]);
});
it("puts a missing value last in either direction", () => {
const active = rankingRows(rows);
expect(ids(sortRanking(active, "trend", "desc"))).toEqual(["contoso", "northwind", "fabrikam"]);
expect(ids(sortRanking(active, "trend", "asc"))).toEqual(["northwind", "contoso", "fabrikam"]);
expect(ids(sortRanking(active, "portalUsers", "asc"))).toEqual(["northwind", "contoso", "fabrikam"]);
});
it("searches the domain, the company and the tenant id, case-insensitively", () => {
const named = [tenant("aaaaaaaa-1111", { domainName: "contoso.example", companyName: "Contoso Ltd" }), tenant("bbbbbbbb-2222")];
expect(ids(searchTenants(named, "CONTOSO"))).toEqual(["aaaaaaaa-1111"]);
expect(ids(searchTenants(named, "ltd"))).toEqual(["aaaaaaaa-1111"]);
expect(ids(searchTenants(named, "bbbbbbbb"))).toEqual(["bbbbbbbb-2222"]);
expect(searchTenants(named, " ")).toBe(named);
});
});
describe("display", () => {
it("shows the route's trend, with 'new' for a new tenant and a dash without a window before", () => {
expect(trendText({ stage: "new", trendPercent: undefined })).toBe("new");
expect(trendText({ stage: "active", trendPercent: undefined })).toBe("—");
expect(trendText({ stage: "active", trendPercent: 25 })).toBe("+25 %");
expect(trendText({ stage: "declining", trendPercent: -60 })).toBe("-60 %");
expect(trendText({ stage: "active", trendPercent: 0 })).toBe("0 %");
});
it("formats percentages, shares and changes", () => {
expect(percentText(undefined)).toBe("—");
expect(percentText(83)).toBe("83 %");
expect(shareText(5, 0)).toBe("—");
expect(shareText(1, 400)).toBe("<1 %");
expect(shareText(0, 400)).toBe("0 %");
expect(shareText(100, 400)).toBe("25 %");
expect(changeText(undefined, 30)).toBe("none in the 30 days before");
expect(changeText(12, 30)).toBe("+12 % vs the 30 days before");
expect(changeText(-5, 1)).toBe("-5 % vs the day before");
});
it("counts UTC days back from now", () => {
expect(agoText(null, NOW)).toBe("—");
expect(agoText(Date.UTC(2026, 10, 6, 0, 5), NOW)).toBe("today");
expect(agoText(Date.UTC(2026, 10, 5, 23, 59), NOW)).toBe("yesterday");
expect(agoText(Date.UTC(2026, 10, 1), NOW)).toBe("5 days ago");
});
it("lists the window's days oldest first", () => {
expect(windowDayKeys("2026-10-30", 7)).toEqual([
"2026-10-30", "2026-10-31", "2026-11-01", "2026-11-02", "2026-11-03", "2026-11-04", "2026-11-05",
]);
expect(windowDayKeys("", 7)).toEqual([]);
});
it("stacks the chart as succeeded, failed and the rest, never below zero", () => {
expect(dailyChartRows([
{ day: "2026-11-05", enrollments: 10, succeeded: 7, failed: 2, activeTenants: 3 },
{ day: "2026-11-04", enrollments: 1, succeeded: 1, failed: 1, activeTenants: 1 },
])).toEqual([
{ day: "2026-11-05", succeeded: 7, failed: 2, other: 1, enrollments: 10, activeTenants: 3 },
{ day: "2026-11-04", succeeded: 1, failed: 1, other: 0, enrollments: 1, activeTenants: 1 },
]);
});
it("reads a wire edition as a plan facet value, an unknown one as Community", () => {
expect(planEditionOf("pro-msp")).toBe("pro-msp");
expect(planEditionOf("pro-trial")).toBe("pro-trial");
expect(planEditionOf("enterprise")).toBe("community");
});
});
describe("lifecycle", () => {
const tenants = [
tenant("n-old", { stage: "neverEnrolled", onboardedAt: "2026-09-01T10:00:00Z" }),
tenant("d-small", { stage: "dormant", lifetimeEnrollments: 4, lastActiveDay: "2026-09-20" }),
tenant("w-older", { stage: "new", firstEnrollmentDay: "2026-10-20", enrollments: 3 }),
tenant("c-small", { stage: "declining", enrollmentsPrevious: 10, enrollments: 5 }),
tenant("n-new", { stage: "neverEnrolled", onboardedAt: "2026-11-04T09:00:00.1234567Z" }),
tenant("w-today", { stage: "new" }),
tenant("d-big", { stage: "dormant", lifetimeEnrollments: 400 }),
tenant("c-big", { stage: "declining", enrollmentsPrevious: 80, enrollments: 10 }),
tenant("w-newer", { stage: "new", firstEnrollmentDay: "2026-11-02", enrollments: 1 }),
tenant("n-unknown", { stage: "neverEnrolled" }),
tenant("a", { stage: "active", enrollments: 9 }),
];
it("groups the four stages in their order, each sorted by what matters for it", () => {
const groups = lifecycleGroups(tenants);
expect(groups.map((g) => g.stage)).toEqual([...LIFECYCLE_STAGES]);
expect(groups.map((g) => ids(g.rows))).toEqual([
["w-today", "w-newer", "w-older"],
["c-big", "c-small"],
["d-big", "d-small"],
["n-new", "n-old", "n-unknown"],
]);
});
it("keeps the route's order on a tie", () => {
const tied = [tenant("x", { stage: "dormant", lifetimeEnrollments: 7 }), tenant("y", { stage: "dormant", lifetimeEnrollments: 7 })];
expect(ids(lifecycleGroups(tied)[2].rows)).toEqual(["x", "y"]);
});
it("shows the figures each stage is about", () => {
expect(lifecycleFigures("new", tenants[2], NOW, true)).toBe(`first sessions ${formatDayKey("2026-10-20")} · 3 in this window`);
expect(lifecycleFigures("new", tenants[5], NOW, true)).toBe("first sessions today · 0 in this window");
expect(lifecycleFigures("declining", tenants[3], NOW, true)).toBe("10 → 5 · last activity —");
expect(lifecycleFigures("dormant", tenants[1], NOW, true)).toBe(`4 since signup · last sessions ${formatDayKey("2026-09-20")}`);
expect(lifecycleFigures("neverEnrolled", tenants[0], NOW, true)).toBe(`signed up ${formatDayKey("2026-09-01")} · no portal visit`);
expect(lifecycleFigures("neverEnrolled", { ...tenants[0], lastPortalVisitAt: "2026-11-04T12:00:00Z" }, NOW, true))
.toBe(`signed up ${formatDayKey("2026-09-01")} · portal 2 days ago`);
expect(lifecycleFigures("neverEnrolled", tenants[0], NOW, false)).toBe(`signed up ${formatDayKey("2026-09-01")}`);
expect(lifecycleFigures("neverEnrolled", tenants[9], NOW, false)).toBe("signup unknown");
});
});
@@ -0,0 +1,118 @@
"use client";
import type { ReactNode } from "react";
import { editionLabel, facetOption, type PlanEdition } from "../../components/tenantFilters";
import type { SortDir } from "../lib/useListState";
/** Building blocks of the GA metrics lists (MCP Usage, Tenant Activity). */
/** A disclosure arrow that turns when its row opens. */
export function Chevron({ open }: { open: boolean }) {
return (
<svg
className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
/** A column label that sorts: ▲/▼ on the active column, ↕ on the others. */
export function SortHeader<K extends string>({
sortKey,
current,
dir,
onSort,
children,
align = "left",
}: {
sortKey: K;
current: K;
dir: SortDir;
onSort: (key: K) => void;
children: ReactNode;
align?: "left" | "right";
}) {
const active = current === sortKey;
return (
<button
type="button"
onClick={() => onSort(sortKey)}
className={`inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 ${align === "right" ? "justify-end" : ""}`}
>
{children}
<span className={`text-[10px] ${active ? "text-indigo-600" : "text-gray-300"}`} aria-hidden="true">
{active ? (dir === "asc" ? "▲" : "▼") : "↕"}
</span>
</button>
);
}
/** A neutral context label (role, plan, origin) in the gray-outline pill style. */
export function Pill({ children, title }: { children: ReactNode; title?: string }) {
return (
<span title={title} className="inline-flex shrink-0 items-center rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
{children}
</span>
);
}
/** The tenant's edition in the colors the tenant lists use. */
export function EditionBadge({ edition }: { edition: PlanEdition }) {
return (
<span className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${facetOption("plan", edition)?.badgeClass ?? ""}`}>
{editionLabel(edition)}
</span>
);
}
/** A number with its own label below lg, where the column header row is hidden; amber for a figure worth a look. */
export function Figure({ label, value, tone }: { label: string; value: string; tone?: "warn" }) {
return (
<span className={`text-sm tabular-nums lg:text-right ${tone === "warn" ? "text-amber-700" : "text-gray-900"}`}>
<span className="text-xs text-gray-500 lg:hidden">{label} </span>
{value}
</span>
);
}
/** A grey line naming a gap in the data (a store that did not answer, a log that reaches back less far). */
export function DataNote({ children }: { children: ReactNode }) {
return <p className="px-4 sm:px-6 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">{children}</p>;
}
/**
* The tenant column of an expandable list row: the disclosure arrow, the name (domain, else the id), the edition
* badge and any extra pills, the company name below.
*/
export function TenantNameCell({
open,
name,
edition,
companyName,
children,
}: {
open: boolean;
name: string;
edition?: PlanEdition;
companyName?: string;
children?: ReactNode;
}) {
return (
<div className="flex min-w-0 basis-full items-start gap-2 lg:basis-auto">
<span className="mt-0.5"><Chevron open={open} /></span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-gray-900 break-all sm:break-normal sm:truncate">{name}</span>
{edition && <EditionBadge edition={edition} />}
{children}
</div>
{companyName && <div className="text-xs text-gray-500 truncate">{companyName}</div>}
</div>
</div>
);
}
@@ -8,8 +8,9 @@ import type {
McpUsageQuotaHitItem,
McpUsageTelemetryStatus,
} from "@/lib/generated/wire-types.generated";
import { formatDayKey, minuteRowsToShow, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
import { DataNote, Pill } from "./parts";
import { formatDayKey } from "../../lib/format";
import { DataNote, Pill } from "../listParts";
import { minuteRowsToShow, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
function limitText(value: number | undefined): string {
if (value == null) return "edition default";
@@ -7,17 +7,11 @@ 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";
import { formatDayKey } from "../../lib/format";
import { useOpenSet, useSortState, type SortDir } from "../../lib/useListState";
import { Figure, Pill, SortHeader, TenantNameCell } from "../listParts";
import { searchTenants, sortAccounts, sortTenants, type TenantInfo, type UsageSortKey } from "./mcpUsageLogic";
import { HitsPill } from "./parts";
// 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";
@@ -102,21 +96,11 @@ export function TenantsTab({ tenants, info }: { tenants: readonly McpUsageTenant
aria-expanded={isOpen}
className={`w-full text-left px-4 sm:px-6 py-3 hover:bg-gray-50 flex flex-wrap items-center gap-x-4 gap-y-2 ${COLS}`}
>
<div className="flex min-w-0 basis-full items-start gap-2 lg:basis-auto">
<span className="mt-0.5"><Chevron open={isOpen} /></span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-gray-900 break-all sm:break-normal sm:truncate">
{t?.domainName ?? tenant.tenantId}
</span>
{t && <EditionBadge edition={t.edition} />}
{!isDefaultPlan(tenant.quota.tenantPlan, t) && <Pill title="Usage plan set for this tenant">plan {tenant.quota.tenantPlan}</Pill>}
{t?.mcpDisabled && <Pill title="MCP is closed for this tenant">MCP closed</Pill>}
<HitsPill count={tenant.quotaHits} title="Quota refusals in this window" />
</div>
{t?.companyName && <div className="text-xs text-gray-500 truncate">{t.companyName}</div>}
</div>
</div>
<TenantNameCell open={isOpen} name={t?.domainName ?? tenant.tenantId} edition={t?.edition} companyName={t?.companyName}>
{!isDefaultPlan(tenant.quota.tenantPlan, t) && <Pill title="Usage plan set for this tenant">plan {tenant.quota.tenantPlan}</Pill>}
{t?.mcpDisabled && <Pill title="MCP is closed for this tenant">MCP closed</Pill>}
<HitsPill count={tenant.quotaHits} title="Quota refusals in this window" />
</TenantNameCell>
<Figure label="Accounts" value={apps > 0 ? `${tenant.accounts.length - apps} + ${apps} app${apps === 1 ? "" : "s"}` : String(tenant.accounts.length)} />
<Figure label="Calls" value={tenant.calls.toLocaleString()} />
<Figure label="Requests" value={tenant.requests.toLocaleString()} />
@@ -2,9 +2,10 @@
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";
import { formatDayKey } from "../../lib/format";
import { useOpenSet, useSortState, type SortDir } from "../../lib/useListState";
import { Chevron, DataNote, Figure, Pill, SortHeader } from "../listParts";
import { formatChars, formatDuration, formatShare, sortTools, type ToolSortKey } from "./mcpUsageLogic";
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";
@@ -5,6 +5,7 @@
* quota — the page never mixes the two in one figure.
*/
import { effectiveEdition, type PlanEdition } from "../../../components/tenantFilters";
import type { SortDir } from "../../lib/useListState";
import type {
GetGlobalMcpUsageOverviewResponse,
McpUsageAccountItem,
@@ -97,7 +98,6 @@ export function tenantNearLimit(t: McpUsageTenantItem): boolean {
// ── 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;
@@ -262,13 +262,6 @@ export function planLegend(response: PlanTierDefinitionsResponse | null): PlanLe
// ── 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 "—";
@@ -1,73 +1,5 @@
"use client";
import type { ReactNode } from "react";
import { editionLabel, facetOption, type PlanEdition } from "../../../components/tenantFilters";
import type { SortDir } from "./mcpUsageLogic";
/** A disclosure arrow that turns when its row opens. */
export function Chevron({ open }: { open: boolean }) {
return (
<svg
className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
/** A column label that sorts: ▲/▼ on the active column, ↕ on the others. */
export function SortHeader<K extends string>({
sortKey,
current,
dir,
onSort,
children,
align = "left",
}: {
sortKey: K;
current: K;
dir: SortDir;
onSort: (key: K) => void;
children: ReactNode;
align?: "left" | "right";
}) {
const active = current === sortKey;
return (
<button
type="button"
onClick={() => onSort(sortKey)}
className={`inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 ${align === "right" ? "justify-end" : ""}`}
>
{children}
<span className={`text-[10px] ${active ? "text-indigo-600" : "text-gray-300"}`} aria-hidden="true">
{active ? (dir === "asc" ? "▲" : "▼") : "↕"}
</span>
</button>
);
}
/** A neutral context label (role, plan, origin) in the gray-outline pill style. */
export function Pill({ children, title }: { children: ReactNode; title?: string }) {
return (
<span title={title} className="inline-flex shrink-0 items-center rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
{children}
</span>
);
}
/** The tenant's edition in the colors the tenant lists use. */
export function EditionBadge({ edition }: { edition: PlanEdition }) {
return (
<span className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${facetOption("plan", edition)?.badgeClass ?? ""}`}>
{editionLabel(edition)}
</span>
);
}
/** A count of refusals: red when there were any, nothing otherwise. */
export function HitsPill({ count, title }: { count: number; title: string }) {
if (count <= 0) return null;
@@ -77,18 +9,3 @@ export function HitsPill({ count, title }: { count: number; title: string }) {
</span>
);
}
/** A number with its own label below lg, where the column header row is hidden; amber for a figure worth a look. */
export function Figure({ label, value, tone }: { label: string; value: string; tone?: "warn" }) {
return (
<span className={`text-sm tabular-nums lg:text-right ${tone === "warn" ? "text-amber-700" : "text-gray-900"}`}>
<span className="text-xs text-gray-500 lg:hidden">{label} </span>
{value}
</span>
);
}
/** A grey line naming a gap in the data (a store that did not answer, a log that reaches back less far). */
export function DataNote({ children }: { children: ReactNode }) {
return <p className="px-4 sm:px-6 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">{children}</p>;
}
@@ -0,0 +1,86 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { TenantActivityTenantItem } from "@/lib/generated/wire-types.generated";
import { tenantManagementUrl } from "@/lib/routes";
import { planEditionOf } from "../../../components/tenantFilters";
import { Chevron, EditionBadge } from "../listParts";
import { lifecycleFigures, lifecycleGroups, type LifecycleStage } from "./tenantActivityLogic";
const STAGE_TEXT: Record<LifecycleStage, { title: string; hint: string }> = {
new: { title: "New", hint: "first sessions in this window" },
declining: { title: "Declining", hint: "at most half the enrollments of the window before, which had 5 or more" },
dormant: { title: "Dormant", hint: "enrolled before, no activity in the last 30 days" },
neverEnrolled: { title: "Never enrolled", hint: "signed up, no enrollment on record" },
};
// The long lists start closed; a click opens them.
const INITIALLY_CLOSED: readonly LifecycleStage[] = ["dormant", "neverEnrolled"];
/** The tenants that need a look, one group per lifecycle stage, one line per tenant. */
export function LifecycleTab({
tenants,
portalAvailable,
nowMs,
}: {
tenants: readonly TenantActivityTenantItem[];
portalAvailable: boolean;
nowMs: number;
}) {
const groups = useMemo(() => lifecycleGroups(tenants), [tenants]);
const [closed, setClosed] = useState<ReadonlySet<LifecycleStage>>(() => new Set(INITIALLY_CLOSED));
const toggle = (stage: LifecycleStage) =>
setClosed((current) => {
const next = new Set(current);
if (!next.delete(stage)) next.add(stage);
return next;
});
return (
<div className="space-y-4">
{groups.map(({ stage, rows }) => {
const isOpen = !closed.has(stage);
return (
<section key={stage} aria-label={STAGE_TEXT[stage].title} className="overflow-hidden rounded-lg bg-white shadow">
<button
type="button"
onClick={() => toggle(stage)}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-gray-50 sm:px-6"
>
<Chevron open={isOpen} />
<span className="font-medium text-gray-900">{STAGE_TEXT[stage].title}</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium tabular-nums text-gray-700">
{rows.length.toLocaleString()}
</span>
<span className="ml-auto hidden truncate text-xs text-gray-500 sm:inline">{STAGE_TEXT[stage].hint}</span>
</button>
{isOpen &&
(rows.length === 0 ? (
<p className="border-t border-gray-200 px-4 py-3 text-sm text-gray-500 sm:px-6">No tenant in this stage.</p>
) : (
<ul className="divide-y divide-gray-100 border-t border-gray-200">
{rows.map((tenant) => (
<li key={tenant.tenantId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-1.5 text-sm sm:px-6">
<Link
href={tenantManagementUrl(tenant.tenantId)}
title="Open in Tenant Management"
className="min-w-0 truncate text-gray-900 hover:text-blue-700 hover:underline"
>
{tenant.domainName || tenant.tenantId}
</Link>
<EditionBadge edition={planEditionOf(tenant.edition)} />
<span className="w-full text-xs tabular-nums text-gray-500 sm:ml-auto sm:w-auto">
{lifecycleFigures(stage, tenant, nowMs, portalAvailable)}
</span>
</li>
))}
</ul>
))}
</section>
);
})}
</div>
);
}
@@ -0,0 +1,200 @@
"use client";
import { useDeferredValue, useMemo, useState } from "react";
import Link from "next/link";
import { SearchInput } from "@/components/SearchInput";
import { chartColors } from "@/components/charts/chartTheme";
import type { TenantActivityTenantItem } from "@/lib/generated/wire-types.generated";
import { parseUtcMs } from "@/lib/osUpdateLive";
import { tenantManagementUrl } from "@/lib/routes";
import { planEditionOf } from "../../../components/tenantFilters";
import { formatDayKey } from "../../lib/format";
import { useOpenSet, useSortState, type SortDir } from "../../lib/useListState";
import { DataNote, Figure, SortHeader, TenantNameCell } from "../listParts";
import {
agoText,
lastActiveMs,
percentText,
rankingRows,
searchTenants,
shareText,
sortRanking,
trendText,
windowDayKeys,
type RankingSortKey,
} from "./tenantActivityLogic";
// One grid for the header and every row from lg up; below lg the rows wrap and each figure carries its label.
const GRID = "lg:grid lg:grid-cols-[minmax(0,1fr)_6rem_4rem_4.5rem_4.5rem_5rem_5rem_7rem] lg:items-center lg:gap-4";
// Success starts with the weakest: on this column the tenants worth a look come first.
const FIRST_DIR: Record<RankingSortKey, SortDir> = {
enrollments: "desc",
trend: "desc",
success: "asc",
activeDays: "desc",
portalUsers: "desc",
lastActive: "desc",
};
const LAST_ACTIVITY_HINT = "Agent registration, first success or ingest — rarely a counter repair";
const LOW_SUCCESS_PERCENT = 80;
const STEEP_DROP_PERCENT = -50;
interface RankingTabProps {
tenants: readonly TenantActivityTenantItem[];
totalEnrollments: number;
windowStart: string;
windowDays: number;
portalAvailable: boolean;
nowMs: number;
}
/** Every tenant with enrollments in the window, sortable and searchable; a row opens its days and lifetime figures. */
export function RankingTab({ tenants, totalEnrollments, windowStart, windowDays, portalAvailable, nowMs }: RankingTabProps) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const { sortKey, sortDir, onSort } = useSortState<RankingSortKey>("enrollments", FIRST_DIR);
const { open, toggle } = useOpenSet();
const active = useMemo(() => rankingRows(tenants), [tenants]);
const visible = useMemo(
() => sortRanking(searchTenants(active, deferredQuery), sortKey, sortDir),
[active, deferredQuery, sortKey, sortDir],
);
const dayKeys = useMemo(() => windowDayKeys(windowStart, windowDays), [windowStart, windowDays]);
const header = (key: RankingSortKey, label: string, align: "left" | "right" = "right") => (
<SortHeader sortKey={key} current={sortKey} dir={sortDir} onSort={onSort} align={align}>
{label}
</SortHeader>
);
return (
<section aria-label="Tenants by enrollments" className="bg-white rounded-lg shadow overflow-hidden">
<div className="flex flex-wrap items-center gap-3 border-b border-gray-200 px-4 py-3 sm:px-6">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Domain, company or tenant id"
ariaLabel="Search tenants"
name="tenant-activity-search"
tone="slate"
/>
<span className="text-xs text-gray-500">
{visible.length === active.length
? `${active.length.toLocaleString()} tenants with enrollments`
: `${visible.length.toLocaleString()} of ${active.length.toLocaleString()} tenants`}
</span>
</div>
{!portalAvailable && <DataNote>The sign-in history did not answer: the portal users are missing.</DataNote>}
<div className={`hidden ${GRID} border-b border-gray-200 bg-gray-50 px-4 py-2 sm:px-6`}>
<span className="text-xs font-medium uppercase tracking-wider text-gray-500">Tenant</span>
<span className="text-right">{header("enrollments", "Enrollments")}</span>
<span className="text-right text-xs font-medium uppercase tracking-wider text-gray-500">Share</span>
<span className="text-right">{header("trend", "Trend")}</span>
<span className="text-right">{header("success", "Success")}</span>
<span className="text-right">{header("activeDays", "Active days")}</span>
<span className="text-right">{header("portalUsers", "Portal users")}</span>
{header("lastActive", "Last activity", "left")}
</div>
{visible.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-gray-500">
{active.length === 0 ? "No tenant enrolled a device in this window." : `No tenant matches “${deferredQuery.trim()}”.`}
</p>
) : (
<ul className="divide-y divide-gray-200">
{visible.map((tenant) => {
const isOpen = open.has(tenant.tenantId);
const trendTone = tenant.trendPercent != null && tenant.trendPercent <= STEEP_DROP_PERCENT ? "warn" : undefined;
const successTone = tenant.successPercent != null && tenant.successPercent < LOW_SUCCESS_PERCENT ? "warn" : undefined;
return (
<li key={tenant.tenantId}>
<button
type="button"
onClick={() => toggle(tenant.tenantId)}
aria-expanded={isOpen}
className={`flex w-full flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 text-left hover:bg-gray-50 sm:px-6 ${GRID}`}
>
<TenantNameCell
open={isOpen}
name={tenant.domainName || tenant.tenantId}
edition={planEditionOf(tenant.edition)}
companyName={tenant.companyName}
/>
<Figure label="Enrollments" value={tenant.enrollments.toLocaleString()} />
<Figure label="Share" value={shareText(tenant.enrollments, totalEnrollments)} />
<Figure label="Trend" value={trendText(tenant)} tone={trendTone} />
<Figure label="Success" value={percentText(tenant.successPercent)} tone={successTone} />
<Figure label="Active days" value={`${tenant.activeDays}/${windowDays}`} />
<Figure label="Portal users" value={tenant.portalUsers == null ? "—" : tenant.portalUsers.toLocaleString()} />
<span className="text-xs text-gray-500" title={LAST_ACTIVITY_HINT}>
<span className="lg:hidden">Last activity </span>
{agoText(lastActiveMs(tenant), nowMs)}
</span>
</button>
{isOpen && <TenantDetail tenant={tenant} dayKeys={dayKeys} portalAvailable={portalAvailable} nowMs={nowMs} />}
</li>
);
})}
</ul>
)}
</section>
);
}
function TenantDetail({
tenant,
dayKeys,
portalAvailable,
nowMs,
}: {
tenant: TenantActivityTenantItem;
dayKeys: readonly string[];
portalAvailable: boolean;
nowMs: number;
}) {
const perDay = tenant.dailyEnrollments ?? [];
const peak = Math.max(1, ...perDay);
const facts: Array<[string, string]> = [
["Enrollments since signup", tenant.lifetimeEnrollments.toLocaleString()],
["Succeeded since signup", tenant.lifetimeSucceeded.toLocaleString()],
["Events since signup", tenant.lifetimeEvents.toLocaleString()],
["Signed up", tenant.onboardedAt ? formatDayKey(tenant.onboardedAt.slice(0, 10), { day: "numeric", month: "short", year: "numeric" }) : "—"],
["First sessions", tenant.firstEnrollmentDay ? formatDayKey(tenant.firstEnrollmentDay, { day: "numeric", month: "short", year: "numeric" }) : "—"],
["Last portal visit", portalAvailable ? agoText(parseUtcMs(tenant.lastPortalVisitAt), nowMs) : "—"],
];
return (
<div className="space-y-3 border-t border-gray-200 bg-gray-50 px-4 py-3 sm:px-6">
<div className="flex h-12 items-end gap-px" role="img" aria-label="Enrollments per day in this window">
{perDay.map((count, index) => (
<span
key={dayKeys[index] ?? index}
className="min-w-px flex-1 rounded-sm"
style={{
height: `${count > 0 ? Math.max(8, (100 * count) / peak) : 3}%`,
backgroundColor: count > 0 ? chartColors.primary : chartColors.muted,
opacity: count > 0 ? 1 : 0.35,
}}
title={`${dayKeys[index] ? formatDayKey(dayKeys[index]) : ""}: ${count.toLocaleString()}`}
/>
))}
</div>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3 lg:grid-cols-6">
{facts.map(([label, value]) => (
<div key={label}>
<dt className="text-xs text-gray-500">{label}</dt>
<dd className="tabular-nums text-gray-900">{value}</dd>
</div>
))}
</dl>
<div className="text-right">
<Link href={tenantManagementUrl(tenant.tenantId)} className="text-xs text-blue-700 hover:underline">
Open in Tenant Management
</Link>
</div>
</div>
);
}
@@ -0,0 +1,214 @@
/**
* Pure folds behind the GA Tenant Activity page (/admin/metrics/tenant-activity). The route
* (GET global/metrics/tenant-activity) computes every figure once for the page and the MCP tool
* get_tenant_activity — stages, trends, success rates, totals; this module only filters, sorts, groups and
* formats them.
*/
import { SHARED_MANIFEST } from "@/lib/generated/shared-manifests.generated";
import type { TenantActivityDailyItem, TenantActivityTenantItem } from "@/lib/generated/wire-types.generated";
import { parseUtcMs } from "@/lib/osUpdateLive";
import { formatDayKey } from "../../lib/format";
import type { SortDir } from "../../lib/useListState";
// ── View (?view=) ─────────────────────────────────────────────────────────────
export type TenantActivityView = "ranking" | "lifecycle";
export const TENANT_ACTIVITY_VIEW_PARAM = "view";
/** The tab a link asks for; anything unknown is the ranking. */
export function parseView(raw: string | null | undefined): TenantActivityView {
return raw === "lifecycle" ? "lifecycle" : "ranking";
}
/** The daily rows keep 180 days and the trend compares with the window before: a longer ?days= shows 90. */
export const TENANT_ACTIVITY_MAX_DAYS = 90;
export type TenantActivityStage = (typeof SHARED_MANIFEST.tenantActivityStages)[number];
// ── Ranking ───────────────────────────────────────────────────────────────────
export type RankingSortKey = "enrollments" | "trend" | "success" | "activeDays" | "portalUsers" | "lastActive";
/** The tenants with enrollments in the window, in the route's order (most first). */
export function rankingRows(tenants: readonly TenantActivityTenantItem[]): TenantActivityTenantItem[] {
return tenants.filter((t) => t.enrollments > 0);
}
/** UTC midnight of a "yyyy-MM-dd" day key, null without one. */
function dayKeyMs(key: string | undefined): number | null {
return key ? parseUtcMs(`${key}T00:00:00Z`) : null;
}
/** When the tenant was last active: the counters' last move, else its last day with sessions. */
export function lastActiveMs(t: TenantActivityTenantItem): number | null {
return parseUtcMs(t.lastCounterUpdateAt) ?? dayKeyMs(t.lastActiveDay);
}
function sortValue(t: TenantActivityTenantItem, key: RankingSortKey): number | null {
switch (key) {
case "enrollments":
return t.enrollments;
case "trend":
return t.trendPercent ?? null;
case "success":
return t.successPercent ?? null;
case "activeDays":
return t.activeDays;
case "portalUsers":
return t.portalUsers ?? null;
case "lastActive":
return lastActiveMs(t);
}
}
/** A sorted copy: a missing value sorts last in either direction, and ties keep the route's order. */
export function sortRanking(
rows: readonly TenantActivityTenantItem[],
key: RankingSortKey,
dir: SortDir,
): TenantActivityTenantItem[] {
const sign = dir === "asc" ? 1 : -1;
return rows
.map((row, index) => ({ row, index, value: sortValue(row, key) }))
.sort((a, b) => {
if (a.value === null || b.value === null) {
if (a.value === b.value) return a.index - b.index;
return a.value === null ? 1 : -1;
}
return a.value === b.value ? a.index - b.index : (a.value - b.value) * sign;
})
.map(({ row }) => row);
}
/** Case-insensitive match on the domain, the company name or the tenant id; an empty query keeps every row. */
export function searchTenants(rows: readonly TenantActivityTenantItem[], query: string): readonly TenantActivityTenantItem[] {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(t) =>
t.domainName.toLowerCase().includes(q) ||
(t.companyName ?? "").toLowerCase().includes(q) ||
t.tenantId.toLowerCase().includes(q),
);
}
// ── Display ───────────────────────────────────────────────────────────────────
/** The trend against the window before: "new" for a new tenant, "—" without a window before, else signed. */
export function trendText(t: Pick<TenantActivityTenantItem, "stage" | "trendPercent">): string {
if (t.stage === "new") return "new";
if (t.trendPercent == null) return "—";
return t.trendPercent > 0 ? `+${t.trendPercent} %` : `${t.trendPercent} %`;
}
/** A whole percentage the route computed, "—" without one. */
export function percentText(value: number | null | undefined): string {
return value == null ? "—" : `${value} %`;
}
/** A tenant's share of the window's enrollments: whole percent, "<1 %" for a small one, "—" without a total. */
export function shareText(part: number, whole: number): string {
if (whole <= 0) return "—";
const percent = (100 * part) / whole;
return part > 0 && percent < 1 ? "<1 %" : `${Math.round(percent)} %`;
}
/** A platform total against the window before: "+12 % vs the 30 days before", or why there is no comparison. */
export function changeText(percent: number | null | undefined, days: number): string {
const before = days === 1 ? "the day before" : `the ${days} days before`;
if (percent == null) return `none in ${before}`;
return `${percent > 0 ? "+" : ""}${percent} % vs ${before}`;
}
const DAY_MS = 86_400_000;
/** "today", "yesterday" or "N days ago", counted in UTC days; "—" without a time. */
export function agoText(ms: number | null, nowMs: number): string {
if (ms == null) return "—";
const days = Math.floor(nowMs / DAY_MS) - Math.floor(ms / DAY_MS);
if (days <= 0) return "today";
return days === 1 ? "yesterday" : `${days} days ago`;
}
/** The window's UTC day keys, oldest first — the days behind a tenant's dailyEnrollments. */
export function windowDayKeys(windowStart: string, days: number): string[] {
const startMs = dayKeyMs(windowStart);
if (startMs == null) return [];
return Array.from({ length: days }, (_, i) => new Date(startMs + i * DAY_MS).toISOString().slice(0, 10));
}
// ── Chart ─────────────────────────────────────────────────────────────────────
/** One bar per window day: succeeded, failed and the rest (still running at aggregation, or incomplete). */
export function dailyChartRows(daily: readonly TenantActivityDailyItem[]): Array<Record<string, unknown>> {
return daily.map((d) => ({
day: d.day,
succeeded: d.succeeded,
failed: d.failed,
other: Math.max(0, d.enrollments - d.succeeded - d.failed),
enrollments: d.enrollments,
activeTenants: d.activeTenants,
}));
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
/** The stages the lifecycle tab lists, in its order. */
export const LIFECYCLE_STAGES = ["new", "declining", "dormant", "neverEnrolled"] as const satisfies readonly TenantActivityStage[];
export type LifecycleStage = (typeof LIFECYCLE_STAGES)[number];
/** Larger first; equal values (two infinities included) are a tie. */
function descending(a: number, b: number): number {
if (a === b) return 0;
return a < b ? 1 : -1;
}
const LIFECYCLE_ORDER: Record<LifecycleStage, (a: TenantActivityTenantItem, b: TenantActivityTenantItem) => number> = {
// Newest first; first sessions only the counters have seen yet carry no day and are the newest of all.
new: (a, b) => descending(dayKeyMs(a.firstEnrollmentDay) ?? Infinity, dayKeyMs(b.firstEnrollmentDay) ?? Infinity),
// The largest drop first.
declining: (a, b) => descending(a.enrollmentsPrevious - a.enrollments, b.enrollmentsPrevious - b.enrollments),
// The biggest customers gone quiet first.
dormant: (a, b) => descending(a.lifetimeEnrollments, b.lifetimeEnrollments),
// The latest signups first.
neverEnrolled: (a, b) => descending(parseUtcMs(a.onboardedAt) ?? -Infinity, parseUtcMs(b.onboardedAt) ?? -Infinity),
};
/** The one line a lifecycle row shows after the tenant: the two or three figures its stage is about. */
export function lifecycleFigures(
stage: LifecycleStage,
t: TenantActivityTenantItem,
nowMs: number,
portalAvailable: boolean,
): string {
switch (stage) {
case "new":
return `first sessions ${t.firstEnrollmentDay ? formatDayKey(t.firstEnrollmentDay) : "today"} · ${t.enrollments.toLocaleString()} in this window`;
case "declining":
return `${t.enrollmentsPrevious.toLocaleString()} → ${t.enrollments.toLocaleString()} · last activity ${agoText(lastActiveMs(t), nowMs)}`;
case "dormant":
return `${t.lifetimeEnrollments.toLocaleString()} since signup · last sessions ${t.lastActiveDay ? formatDayKey(t.lastActiveDay) : "unknown"}`;
case "neverEnrolled": {
const signup = t.onboardedAt ? `signed up ${formatDayKey(t.onboardedAt.slice(0, 10))}` : "signup unknown";
if (!portalAvailable) return signup;
const visit = parseUtcMs(t.lastPortalVisitAt);
return `${signup} · ${visit == null ? "no portal visit" : `portal ${agoText(visit, nowMs)}`}`;
}
}
}
/** Every lifecycle stage with its tenants in the stage's order; ties keep the route's order. */
export function lifecycleGroups(
tenants: readonly TenantActivityTenantItem[],
): Array<{ stage: LifecycleStage; rows: TenantActivityTenantItem[] }> {
return LIFECYCLE_STAGES.map((stage) => ({
stage,
rows: tenants
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.stage === stage)
.sort((a, b) => LIFECYCLE_ORDER[stage](a.row, b.row) || a.index - b.index)
.map(({ row }) => row),
}));
}
@@ -22,6 +22,22 @@ html {
drawer + overlay) keep sizing against the viewport. */
overflow-x: clip;
scrollbar-gutter: stable;
/* Anchor jumps and scrollIntoView stop below the view-as bar as well (see the next block). */
scroll-padding-top: var(--view-as-offset);
}
/* ===== View-as bar (components/ViewAsBar.tsx) =====
While a tab views a tenant as its member, or says once why that was refused, the bar sticks above the
navbar at a fixed height. Everything positioned against the top of the viewport adds --view-as-offset —
the navbar's sticky top, the desktop sidebar, sticky content, anchor scrolling — so nothing slides under
the bar: the offset is the bar's height while it is shown and 0 otherwise. Offsets computed in JS read
the bar's height through viewAsBarHeight() (lib/viewAs.ts). */
:root {
--view-as-bar-height: 2.25rem;
--view-as-offset: 0px;
}
:root:has([data-view-as-bar]) {
--view-as-offset: var(--view-as-bar-height);
}
:root {
@@ -12,7 +12,7 @@ const ITEMS = [
export function PushNav() {
const pathname = (usePathname() ?? "/push").replace(/\/$/, "") || "/push";
return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<header className="bg-white border-b border-gray-200 sticky top-[var(--view-as-offset)] z-10">
<div className="max-w-md mx-auto px-4 h-12 flex items-center justify-between">
<Link href="/push" className="flex items-center gap-2 min-w-0">
<BrandMark className="w-5 h-5 shrink-0" />
@@ -13,6 +13,7 @@ import { useAdminMode } from "../hooks/useAdminMode";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { DOCS_URL } from "@/lib/config";
import { isPushAppPath } from "@/lib/push/pushAppPath";
import { viewAsBarHeight } from "@/lib/viewAs";
// Sidebar pixel widths
export const SIDEBAR_PX: Record<CollapseState, number> = {
@@ -75,7 +76,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
}
}
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 },
// The top 80 px are the navbar plus a margin; a view-as bar above the navbar adds its own height.
{ rootMargin: `-${80 + viewAsBarHeight()}px 0px -60% 0px`, threshold: 0 },
);
observerRef.current = observer;
@@ -104,7 +106,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
const scrollTo = useCallback((id: string) => {
const el = document.getElementById(id);
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - 90;
// 90 px keep the section clear of the navbar; a view-as bar above the navbar adds its own height.
const y = el.getBoundingClientRect().top + window.scrollY - 90 - viewAsBarHeight();
window.scrollTo({ top: y, behavior: "smooth" });
}
setActiveSectionId(id);
@@ -726,7 +729,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
{/* ===== Desktop: fixed sidebar ===== */}
<aside
className={`hidden md:flex fixed left-0 ${hasNavbar ? "top-14" : "top-0"} bottom-0 z-20 flex-col transition-all duration-200 ease-in-out overflow-hidden ${sidebarWidthClass[collapseState]}`}
className={`hidden md:flex fixed left-0 ${hasNavbar ? "top-[calc(3.5rem+var(--view-as-offset))]" : "top-[var(--view-as-offset)]"} bottom-0 z-20 flex-col transition-all duration-200 ease-in-out overflow-hidden ${sidebarWidthClass[collapseState]}`}
>
{collapseState !== "hidden" && (
<nav className="flex flex-col h-full bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
@@ -144,7 +144,7 @@ export default function Navbar() {
// scope → gets the full navbar.
if (!isAdminOrOperator && !isViewer && !hasGlobalScope) {
return (
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-30">
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-[var(--view-as-offset)] z-30">
<div className="px-3">
<div className="flex justify-between h-14">
<div className="flex items-center">
@@ -193,7 +193,7 @@ export default function Navbar() {
}
return (
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-30">
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-[var(--view-as-offset)] z-30">
<div className="px-3">
<div className="flex justify-between h-14">
{/* Logo and Title */}
@@ -272,7 +272,7 @@ export default function Navbar() {
(!!user?.isGlobalAdmin && visibleGlobal.length > 0);
return (
<div className="fixed sm:absolute top-16 sm:top-auto left-2 right-2 sm:left-auto sm:right-0 mt-0 sm:mt-2 sm:w-96 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[calc(100vh-5rem)] sm:max-h-96 overflow-hidden flex flex-col">
<div className="fixed sm:absolute top-[calc(4rem+var(--view-as-offset))] sm:top-auto left-2 right-2 sm:left-auto sm:right-0 mt-0 sm:mt-2 sm:w-96 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[calc(100vh-5rem-var(--view-as-offset))] sm:max-h-96 overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
{(unreadCount > 0 || hasClearable) && (
@@ -10,7 +10,11 @@ const refusedNotice = typeof window === "undefined" ? null : takeViewAsNotice();
const getRefusedNotice = () => refusedNotice;
const noValue = () => null;
const BAR_CLASS = "bg-purple-700 text-white text-sm px-4 py-2 flex flex-wrap items-center justify-center gap-x-2 gap-y-1";
// One line at a fixed height, sticky above the navbar. While an element carrying data-view-as-bar is shown,
// globals.css moves everything positioned against the top of the viewport down by exactly that height, so the
// bar never wraps: a long text truncates instead.
const BAR_CLASS =
"sticky top-0 z-30 h-[var(--view-as-bar-height)] overflow-hidden whitespace-nowrap bg-purple-700 text-white text-sm px-4 flex items-center justify-center gap-x-2";
/**
* The one deliberate difference of a view-as tab from the customer's own portal: a slim bar that names the
@@ -28,17 +32,17 @@ export function ViewAsBar() {
// The backend's name for the tenant once auth/me answered; the id until then (or when it refused).
const tenantName = user?.viewAs?.tenantName ?? selection.tenantId;
return (
<div className={BAR_CLASS} role="status">
<div data-view-as-bar className={BAR_CLASS} role="status">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="font-medium">Viewing as {tenantName}</span>
<span className="text-purple-300">&mdash; {selection.role}, read-only</span>
<span className="min-w-0 truncate font-medium" title={tenantName}>Viewing as {tenantName}</span>
<span className="flex-shrink-0 text-purple-300">&mdash; {selection.role}, read-only</span>
<button
type="button"
onClick={exitViewAs}
className="ml-2 rounded border border-purple-300 px-2 py-0.5 text-xs font-medium hover:bg-purple-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
className="ml-2 flex-shrink-0 rounded border border-purple-300 px-2 py-0.5 text-xs font-medium hover:bg-purple-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
Exit
</button>
@@ -48,14 +52,14 @@ export function ViewAsBar() {
if (notice && !noticeDismissed) {
return (
<div className={BAR_CLASS} role="alert">
<span className="font-medium">View as ended</span>
<span className="text-purple-300">&mdash; {notice}</span>
<div data-view-as-bar className={BAR_CLASS} role="alert">
<span className="flex-shrink-0 font-medium">View as ended</span>
<span className="min-w-0 truncate text-purple-300" title={notice}>&mdash; {notice}</span>
<button
type="button"
onClick={() => setNoticeDismissed(true)}
aria-label="Dismiss"
className="ml-2 rounded px-1.5 text-purple-200 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
className="ml-2 flex-shrink-0 rounded px-1.5 text-purple-200 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
&times;
</button>
@@ -41,7 +41,7 @@ export default function SeverityPriorityCard({
return (
<section
aria-label="Severity and remediation priority"
className="bg-white rounded-lg shadow lg:col-span-2 lg:sticky lg:top-20"
className="bg-white rounded-lg shadow lg:col-span-2 lg:sticky lg:top-[calc(5rem+var(--view-as-offset))]"
>
<div className="p-5">
<h3 className="text-base font-semibold text-gray-900">Exposure by severity</h3>
+4
View File
@@ -347,6 +347,10 @@ export const api = {
// cross-tenant aggregate. Daily rows are additive, so the window follows days honestly.
globalVerdictCalibration: (days: number, tenantId?: string) =>
`${API_BASE_URL}/api/global/metrics/verdict-calibration${qs({ days: String(days), tenantId })}`,
// Tenant activity (operator-only; GlobalReadOrAdmin): every signed-up tenant's enrollments, lifecycle stage and
// portal users over the last `days` full UTC days, against the days before.
globalTenantActivity: (days: number) =>
`${API_BASE_URL}/api/global/metrics/tenant-activity${qs({ days: String(days) })}`,
geographic: (tenantId: string, days: number, groupBy: string) =>
`${API_BASE_URL}/api/metrics/geographic${qs({ tenantId, days: String(days), groupBy })}`,
globalGeographic: (days: number, groupBy: string, tenantId?: string) =>
@@ -956,6 +956,20 @@ export const SHARED_MANIFEST = {
"sentAtBatchCount"
]
},
"tenantActivityStages": [
"neverEnrolled",
"dormant",
"new",
"declining",
"active",
"quiet"
],
"tenantEditions": [
"community",
"pro",
"pro-trial",
"pro-msp"
],
"diagnosis": {
"outcomes": [
"failed",
@@ -952,6 +952,20 @@
"sentAtBatchCount"
]
},
"tenantActivityStages": [
"neverEnrolled",
"dormant",
"new",
"declining",
"active",
"quiet"
],
"tenantEditions": [
"community",
"pro",
"pro-trial",
"pro-msp"
],
"diagnosis": {
"outcomes": [
"failed",
@@ -12012,6 +12026,88 @@
}
]
},
"GetTenantActivityResponse": {
"kind": "object",
"doc": "Response of GET global/metrics/tenant-activity: how every signed-up tenant used the product in the last N full UTC days, against the N days before. Built from the daily usage rows (written once per day at the first maintenance run after midnight UTC), the per-tenant counters and the portal sign-in history. Operator-only (Global Admin and Global Reader).",
"fields": [
{
"name": "windowDays",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "windowStart",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Inclusive window start (\"yyyy-MM-dd\", UTC)."
},
{
"name": "usageDaysThrough",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Inclusive window end (\"yyyy-MM-dd\", UTC): the last day the daily usage rows cover — yesterday, or the day before while yesterday is not aggregated yet. Today is never in the window."
},
{
"name": "previousWindowStart",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Inclusive start of the previous window of the same length (\"yyyy-MM-dd\")."
},
{
"name": "rowsFrom",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "First day the daily usage rows reach (\"yyyy-MM-dd\", 180 days back); a first day with sessions before it is unknown."
},
{
"name": "portalActivityAvailable",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "False when the portal sign-in history could not be read; every portal figure is then absent."
},
{
"name": "totals",
"type": {
"kind": "ref",
"name": "TenantActivityTotals"
}
},
{
"name": "daily",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "TenantActivityDailyItem"
}
},
"doc": "One entry per window day, oldest first, days without enrollments included."
},
{
"name": "tenants",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "TenantActivityTenantItem"
}
},
"doc": "Every signed-up tenant: most enrollments in the window first, then most enrollments since signup."
}
]
},
"GetTenantConfigFieldsSchemaResponse": {
"kind": "object",
"doc": "Response of GET config/fields-schema: the machine-readable tenant-config field schema for the MCP write surface.",
@@ -25266,6 +25362,415 @@
}
]
},
"TenantActivityDailyItem": {
"kind": "object",
"doc": "One UTC day of the window, summed over the signed-up tenants.",
"fields": [
{
"name": "day",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "UTC day (\"yyyy-MM-dd\")."
},
{
"name": "enrollments",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "succeeded",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Succeeded when the day was aggregated."
},
{
"name": "failed",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Failed when the day was aggregated; the rest was still running or ended incomplete."
},
{
"name": "activeTenants",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Tenants with enrollments that day."
}
]
},
"TenantActivityStageCounts": {
"kind": "object",
"doc": "Tenants per lifecycle stage (TenantActivityStages).",
"fields": [
{
"name": "neverEnrolled",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "dormant",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "new",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "declining",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "active",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "quiet",
"type": {
"kind": "primitive",
"name": "number"
}
}
]
},
"TenantActivityTenantItem": {
"kind": "object",
"doc": "One signed-up tenant.",
"fields": [
{
"name": "tenantId",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "domainName",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "companyName",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
},
{
"name": "edition",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Effective edition (TenantEditionNames)."
},
{
"name": "stage",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Lifecycle stage (TenantActivityStages)."
},
{
"name": "onboardedAt",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "When the configuration row was created (the signup); absent on rows without the field."
},
{
"name": "enrollments",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Enrollment sessions started in the window."
},
{
"name": "enrollmentsPrevious",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Enrollment sessions started in the previous window."
},
{
"name": "trendPercent",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Change against the previous window, whole percent; absent when the previous window had none."
},
{
"name": "succeeded",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Of the window's enrollments, Succeeded when their day was aggregated."
},
{
"name": "failed",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Of the window's enrollments, Failed when their day was aggregated."
},
{
"name": "successPercent",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Succeeded / (Succeeded + Failed), whole percent; absent without a finished session."
},
{
"name": "activeDays",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Window days with enrollments."
},
{
"name": "dailyEnrollments",
"type": {
"kind": "array",
"element": {
"kind": "primitive",
"name": "number"
}
},
"optional": true,
"doc": "Enrollments per window day, oldest first; absent without enrollments in the window."
},
{
"name": "firstEnrollmentDay",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "First UTC day with sessions since signup (\"yyyy-MM-dd\"); absent when unknown — a signup before RowsFrom, or first sessions that only the counters have seen yet."
},
{
"name": "lastActiveDay",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Latest UTC day with sessions (\"yyyy-MM-dd\"; a day after UsageDaysThrough when only the counters moved since); absent without any."
},
{
"name": "lastCounterUpdateAt",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "When the tenant's counters last moved: an agent registration, a first success or an ingest batch — rarely a maintenance repair of the counter floors. Absent without counters."
},
{
"name": "lifetimeEnrollments",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Enrollment sessions since signup (retention-independent counter)."
},
{
"name": "lifetimeSucceeded",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Sessions since signup that reached Succeeded."
},
{
"name": "lifetimeEvents",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Events stored since signup."
},
{
"name": "portalUsers",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "People with a portal visit in the window (counted in the home tenant of their token); absent without the sign-in history."
},
{
"name": "lastPortalVisitAt",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Latest portal visit in the sign-in history (kept 90 days); absent without one or without the history."
}
]
},
"TenantActivityTotals": {
"kind": "object",
"doc": "Platform totals of the window; the tenant rows add up to them.",
"fields": [
{
"name": "signedUpTenants",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Configuration rows with a domain: the signups that exist today."
},
{
"name": "activeTenants",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Tenants with enrollments in the window."
},
{
"name": "activeTenantsPrevious",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Tenants with enrollments in the previous window."
},
{
"name": "activeTenantsTrendPercent",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Change of the active tenants against the previous window, whole percent; absent when the previous window had none."
},
{
"name": "enrollments",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Enrollment sessions started in the window."
},
{
"name": "enrollmentsPrevious",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Enrollment sessions started in the previous window."
},
{
"name": "enrollmentsTrendPercent",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Change of the enrollments against the previous window, whole percent; absent when the previous window had none."
},
{
"name": "succeeded",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Of the window's enrollments, Succeeded when their day was aggregated."
},
{
"name": "failed",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Of the window's enrollments, Failed when their day was aggregated."
},
{
"name": "successPercent",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Succeeded / (Succeeded + Failed), whole percent; absent without a finished session."
},
{
"name": "top10SharePercent",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Share of the window's enrollments that the 10 tenants with the most hold, whole percent; absent without enrollments."
},
{
"name": "stages",
"type": {
"kind": "ref",
"name": "TenantActivityStageCounts"
},
"doc": "Tenants per lifecycle stage; every signed-up tenant is in exactly one."
},
{
"name": "portalUsers",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "People with a portal visit in the window, summed over the tenants; absent without the sign-in history."
},
{
"name": "portalTenants",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Tenants with at least one such person; absent without the sign-in history."
}
]
},
"TenantAdminCreatedResponse": {
"kind": "object",
"doc": "Response of POST tenants/{tenantId}/admins: the created tenant member.",
@@ -2527,6 +2527,26 @@ export interface GetSessionTimeAttributionResponse {
breakdown?: SessionTimeBreakdown;
}
/** Response of GET global/metrics/tenant-activity: how every signed-up tenant used the product in the last N full UTC days, against the N days before. Built from the daily usage rows (written once per day at the first maintenance run after midnight UTC), the per-tenant counters and the portal sign-in history. Operator-only (Global Admin and Global Reader). */
export interface GetTenantActivityResponse {
windowDays: number;
/** Inclusive window start ("yyyy-MM-dd", UTC). */
windowStart: string;
/** Inclusive window end ("yyyy-MM-dd", UTC): the last day the daily usage rows cover — yesterday, or the day before while yesterday is not aggregated yet. Today is never in the window. */
usageDaysThrough: string;
/** Inclusive start of the previous window of the same length ("yyyy-MM-dd"). */
previousWindowStart: string;
/** First day the daily usage rows reach ("yyyy-MM-dd", 180 days back); a first day with sessions before it is unknown. */
rowsFrom: string;
/** False when the portal sign-in history could not be read; every portal figure is then absent. */
portalActivityAvailable: boolean;
totals: TenantActivityTotals;
/** One entry per window day, oldest first, days without enrollments included. */
daily: TenantActivityDailyItem[];
/** Every signed-up tenant: most enrollments in the window first, then most enrollments since signup. */
tenants: TenantActivityTenantItem[];
}
/** Response of GET config/fields-schema: the machine-readable tenant-config field schema for the MCP write surface. */
export interface GetTenantConfigFieldsSchemaResponse {
count: number;
@@ -5524,6 +5544,106 @@ export interface TemplateVariable {
validation?: string;
}
/** One UTC day of the window, summed over the signed-up tenants. */
export interface TenantActivityDailyItem {
/** UTC day ("yyyy-MM-dd"). */
day: string;
enrollments: number;
/** Succeeded when the day was aggregated. */
succeeded: number;
/** Failed when the day was aggregated; the rest was still running or ended incomplete. */
failed: number;
/** Tenants with enrollments that day. */
activeTenants: number;
}
/** Tenants per lifecycle stage (TenantActivityStages). */
export interface TenantActivityStageCounts {
neverEnrolled: number;
dormant: number;
new: number;
declining: number;
active: number;
quiet: number;
}
/** One signed-up tenant. */
export interface TenantActivityTenantItem {
tenantId: string;
domainName: string;
companyName?: string;
/** Effective edition (TenantEditionNames). */
edition: string;
/** Lifecycle stage (TenantActivityStages). */
stage: string;
/** When the configuration row was created (the signup); absent on rows without the field. */
onboardedAt?: string;
/** Enrollment sessions started in the window. */
enrollments: number;
/** Enrollment sessions started in the previous window. */
enrollmentsPrevious: number;
/** Change against the previous window, whole percent; absent when the previous window had none. */
trendPercent?: number;
/** Of the window's enrollments, Succeeded when their day was aggregated. */
succeeded: number;
/** Of the window's enrollments, Failed when their day was aggregated. */
failed: number;
/** Succeeded / (Succeeded + Failed), whole percent; absent without a finished session. */
successPercent?: number;
/** Window days with enrollments. */
activeDays: number;
/** Enrollments per window day, oldest first; absent without enrollments in the window. */
dailyEnrollments?: number[];
/** First UTC day with sessions since signup ("yyyy-MM-dd"); absent when unknown — a signup before RowsFrom, or first sessions that only the counters have seen yet. */
firstEnrollmentDay?: string;
/** Latest UTC day with sessions ("yyyy-MM-dd"; a day after UsageDaysThrough when only the counters moved since); absent without any. */
lastActiveDay?: string;
/** When the tenant's counters last moved: an agent registration, a first success or an ingest batch — rarely a maintenance repair of the counter floors. Absent without counters. */
lastCounterUpdateAt?: string;
/** Enrollment sessions since signup (retention-independent counter). */
lifetimeEnrollments: number;
/** Sessions since signup that reached Succeeded. */
lifetimeSucceeded: number;
/** Events stored since signup. */
lifetimeEvents: number;
/** People with a portal visit in the window (counted in the home tenant of their token); absent without the sign-in history. */
portalUsers?: number;
/** Latest portal visit in the sign-in history (kept 90 days); absent without one or without the history. */
lastPortalVisitAt?: string;
}
/** Platform totals of the window; the tenant rows add up to them. */
export interface TenantActivityTotals {
/** Configuration rows with a domain: the signups that exist today. */
signedUpTenants: number;
/** Tenants with enrollments in the window. */
activeTenants: number;
/** Tenants with enrollments in the previous window. */
activeTenantsPrevious: number;
/** Change of the active tenants against the previous window, whole percent; absent when the previous window had none. */
activeTenantsTrendPercent?: number;
/** Enrollment sessions started in the window. */
enrollments: number;
/** Enrollment sessions started in the previous window. */
enrollmentsPrevious: number;
/** Change of the enrollments against the previous window, whole percent; absent when the previous window had none. */
enrollmentsTrendPercent?: number;
/** Of the window's enrollments, Succeeded when their day was aggregated. */
succeeded: number;
/** Of the window's enrollments, Failed when their day was aggregated. */
failed: number;
/** Succeeded / (Succeeded + Failed), whole percent; absent without a finished session. */
successPercent?: number;
/** Share of the window's enrollments that the 10 tenants with the most hold, whole percent; absent without enrollments. */
top10SharePercent?: number;
/** Tenants per lifecycle stage; every signed-up tenant is in exactly one. */
stages: TenantActivityStageCounts;
/** People with a portal visit in the window, summed over the tenants; absent without the sign-in history. */
portalUsers?: number;
/** Tenants with at least one such person; absent without the sign-in history. */
portalTenants?: number;
}
/** Response of POST tenants/{tenantId}/admins: the created tenant member. */
export interface TenantAdminCreatedResponse {
admin: TenantAdminRow;
@@ -266,6 +266,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
{ id: "ga-platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics") },
{ id: "ga-usage", label: "Platform Usage", href: route("/admin/metrics/usage") },
{ id: "ga-active-users", label: "Active Users", href: "/admin/presence" },
{ id: "ga-tenant-activity", label: "Tenant Activity", href: route("/admin/metrics/tenant-activity") },
{ id: "ga-mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage") },
{ id: "ga-verdict-calibration", label: "Verdict Calibration", href: route("/admin/metrics/verdict-calibration") },
{ id: "ga-ime-pattern-health", label: "IME Pattern Health", href: route("/admin/metrics/ime-pattern-health") },
@@ -88,6 +88,14 @@ export function viewAsHeaders(): Record<string, string> {
return selection ? { "X-View-As-Tenant": selection.tenantId, "X-View-As-Role": selection.role } : {};
}
/**
* How far the view-as bar (components/ViewAsBar.tsx) pushes the navbar down right now, in px; 0 while none is
* shown. CSS offsets add --view-as-offset (globals.css); offsets computed in JS add this.
*/
export function viewAsBarHeight(): number {
return document.querySelector<HTMLElement>("[data-view-as-bar]")?.offsetHeight ?? 0;
}
function clearViewAs(): void {
cached = null;
try {
@@ -110,6 +110,8 @@ const MCP_VOCABULARIES = [
doc: "Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources)." },
{ section: "apiErrorCodes", constName: "API_ERROR_CODES", typeName: "ApiErrorCode",
doc: "Every machine-readable `code` an error envelope can carry (C# Constants.ApiErrorCodes + DelegationCodes + DelegatedSlots)." },
{ section: "tenantActivityStages", constName: "TENANT_ACTIVITY_STAGES", typeName: "TenantActivityStage",
doc: "Lifecycle stages of the tenant activity, in their test order (C# TenantActivityStages)." },
// kind "map": a section of string lists keyed by name, emitted as a record of arrays.
{ section: "analyzeRuleEvidenceKeys", constName: "ANALYZE_RULE_EVIDENCE_KEYS", kind: "map",
doc: "Evidence keys a matched analyze-rule condition writes, per condition source — what a {{signal.key}} token can resolve (C# RuleEvidenceKeys)." },