mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-07 10:40:47 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 225d0feca9 | |||
| 6b06edb222 | |||
| 12b29c2e08 | |||
| 5a37721d24 | |||
| 884f8495b2 | |||
| cc674b26ad | |||
| 7139374681 | |||
| e6d92c6005 |
+1
-1
Submodule .claude/commands updated: 935f869d09...dbacde8e0d
+1
-1
Submodule internal updated: 4038bbcdf3...1a3979773f
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
|
||||
|
||||
+25
-78
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+305
@@ -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" });
|
||||
}
|
||||
+2
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
-6
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+186
@@ -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>;
|
||||
}
|
||||
|
||||
+86
@@ -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>
|
||||
);
|
||||
}
|
||||
+200
@@ -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>
|
||||
);
|
||||
}
|
||||
+214
@@ -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">— {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">— {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">— {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}>— {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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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)." },
|
||||
|
||||
Reference in New Issue
Block a user