diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index f2f49c8f..d98620ef 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -40,6 +40,23 @@ jobs: lfs: false fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates + # The customer docs repo is the single source of the "What's new" panel: the prebuild + # step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into + # public/whats-new.json and dates every bullet via git blame — hence the full history. + # Public repo, no token. Editing the docs does NOT update the live panel until this + # workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json + # records which docs commit is live. + - name: Check out documentation bundle + uses: actions/checkout@v7 + with: + repository: autopilotmonitor/autopilotmonitor-docs + path: whats-new-docs + fetch-depth: 0 + + - name: Resolve docs commit + id: docs + run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v7 with: node-version: '22' @@ -51,7 +68,7 @@ jobs: run: npm ci # next.config output:'export' -- `next build` writes the static site to out/ - # (prebuild: sync-error-codes, generate-lastmod, generate-version). + # (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new). - name: Build working-directory: ${{ env.WEB_DIR }} env: @@ -68,9 +85,12 @@ jobs: # Build stamp for public/version.json (scripts/generate-version.js in prebuild); # the verify step below polls the deployed file for exactly this commit. GITHUB_SHA: ${{ github.sha }} + # Docs checkout for public/whats-new.json; the prebuild fails in CI without it. + WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs run: | npm run build test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; } + test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; } # One archive of the exact bytes that get uploaded, so the attestation has a single subject. - name: Package export @@ -136,7 +156,8 @@ jobs: throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published" # Cross-component deploy record, written last and only on a verified deploy -- - # same contract as versions/backend.json and versions/mcp.json. + # same contract as versions/backend.json and versions/mcp.json. docsCommit says which + # docs revision the live What's new panel was generated from (as for the MCP corpus). - name: Publish version manifest shell: pwsh run: | @@ -145,6 +166,7 @@ jobs: commit = '${{ github.sha }}'.Substring(0, 7) deployedUtc = [DateTime]::UtcNow.ToString('o') runId = '${{ github.run_id }}' + docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7) } | ConvertTo-Json $sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?') diff --git a/.gitignore b/.gitignore index 6144fb07..3f9c61b5 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,10 @@ internal/docs/~$Introduction-AutopilotMonitor.pptx # changes each run) and served at /version.json from the static export. src/Web/autopilot-monitor-web/public/version.json +# What's new payload: generated by scripts/whats-new/build-whats-new.js from the docs +# checkout on every build and served at /whats-new.json from the static export. +src/Web/autopilot-monitor-web/public/whats-new.json + # Local IME installer scratch (MSI + extracted payload per version, for grepping # in binaries). Operator-only, never committed. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs index ea4c2bc1..8f24d638 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs @@ -55,6 +55,76 @@ public class AuthFunctionTests Assert.True((bool)body.hasMcpAccess); } + [Fact] + public void HappyPath_SurfacesWhatsNewSeenMarks() + { + var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc); + + var result = AuthFunction.BuildAuthResult( + DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, + memberRole: AdminRole(), mcpCheck: McpAllowed(), + hasTenantAdmins: true, + TenantId, Upn, DisplayName, ObjectId, + whatsNewSeenPlatformUtc: platformSeen, + whatsNewSeenAgentUtc: agentSeen); + + Assert.True(result.IsSuccess); + var body = ToDynamic(result.Body); + Assert.Equal(platformSeen, (DateTime)body.whatsNewSeenPlatformUtc); + Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc); + } + + [Theory] + [InlineData("platform", "platform")] + [InlineData("Platform", "platform")] + [InlineData("agent", "agent")] + public void TryNormalizeWhatsNewSeen_AcceptsKnownChannels(string input, string expected) + { + var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + + var ok = AuthFunction.TryNormalizeWhatsNewSeen( + new WhatsNewSeenRequest { Channel = input, SeenUtc = seen }, + seen.AddMinutes(1), + out var channel, + out var seenUtc, + out var error); + + Assert.True(ok, error); + Assert.Equal(expected, channel); + Assert.Equal(seen, seenUtc); + } + + [Fact] + public void TryNormalizeWhatsNewSeen_RejectsUnknownChannel() + { + var ok = AuthFunction.TryNormalizeWhatsNewSeen( + new WhatsNewSeenRequest { Channel = "other", SeenUtc = DateTime.UtcNow }, + DateTime.UtcNow, + out _, + out _, + out var error); + + Assert.False(ok); + Assert.Equal("channel must be 'platform' or 'agent'", error); + } + + [Fact] + public void TryNormalizeWhatsNewSeen_ClampsFarFutureTimestamp() + { + var now = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + + var ok = AuthFunction.TryNormalizeWhatsNewSeen( + new WhatsNewSeenRequest { Channel = "agent", SeenUtc = now.AddMinutes(6) }, + now, + out _, + out var seenUtc, + out var error); + + Assert.True(ok, error); + Assert.Equal(now, seenUtc); + } + [Fact] public void HappyPath_OperatorUser_ReturnsCorrectRole() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index ba36c162..99e3521b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -28,6 +28,8 @@ public class InfrastructureWireParityTests var displayName = "Admin User"; var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111"; var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" }; + var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc); AssertParity( new @@ -46,7 +48,9 @@ public class InfrastructureWireParityTests hasMcpAccess = true, homedApp = "primary", bootstrapTokenEnabled = true, - unrestrictedModeEnabled = false + unrestrictedModeEnabled = false, + whatsNewSeenPlatformUtc = platformSeen, + whatsNewSeenAgentUtc = agentSeen }, new AuthMeResponse { @@ -64,7 +68,9 @@ public class InfrastructureWireParityTests HasMcpAccess = true, HomedApp = "primary", BootstrapTokenEnabled = true, - UnrestrictedModeEnabled = false + UnrestrictedModeEnabled = false, + WhatsNewSeenPlatformUtc = platformSeen, + WhatsNewSeenAgentUtc = agentSeen }); } @@ -78,6 +84,8 @@ public class InfrastructureWireParityTests var displayName = "Reader User"; var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222"; string? role = null; + DateTime? whatsNewSeenPlatformUtc = null; + DateTime? whatsNewSeenAgentUtc = null; AssertParity( new @@ -96,7 +104,9 @@ public class InfrastructureWireParityTests hasMcpAccess = true, homedApp = "legacy", bootstrapTokenEnabled = false, - unrestrictedModeEnabled = false + unrestrictedModeEnabled = false, + whatsNewSeenPlatformUtc, + whatsNewSeenAgentUtc }, new AuthMeResponse { @@ -114,7 +124,9 @@ public class InfrastructureWireParityTests HasMcpAccess = true, HomedApp = "legacy", BootstrapTokenEnabled = false, - UnrestrictedModeEnabled = false + UnrestrictedModeEnabled = false, + WhatsNewSeenPlatformUtc = null, + WhatsNewSeenAgentUtc = null }); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UserWhatsNewSeenStorageTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UserWhatsNewSeenStorageTests.cs new file mode 100644 index 00000000..391dd3ca --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UserWhatsNewSeenStorageTests.cs @@ -0,0 +1,126 @@ +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +public class UserWhatsNewSeenStorageTests +{ + private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + private const string Upn = "user@contoso.com"; + + [Fact] + public async Task GetUserWhatsNewSeen_MissingRow_ReturnsNullMarks() + { + var h = new Harness(); + h.Table.Setup(t => t.GetEntityAsync( + TenantId, + TableStorageService.PresenceRowKey(Upn), + It.Is>(s => s.SequenceEqual(new[] { "WhatsNewSeenPlatformUtc", "WhatsNewSeenAgentUtc" })), + It.IsAny())) + .ThrowsAsync(new RequestFailedException(404, "Not found")); + + var seen = await h.Sut.GetUserWhatsNewSeenAsync(TenantId, Upn); + + Assert.Null(seen.PlatformUtc); + Assert.Null(seen.AgentUtc); + } + + [Fact] + public async Task MarkUserWhatsNewSeen_OlderThanExisting_DoesNotWrite() + { + var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + var h = new Harness(); + h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn)) + { + ["WhatsNewSeenPlatformUtc"] = existing + }); + + await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", existing.AddMinutes(-1)); + + Assert.Empty(h.Upserts); + } + + [Fact] + public async Task MarkUserWhatsNewSeen_NewerValue_MergeUpsertsOnlySelectedChannelAndUpn() + { + var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + var newer = existing.AddMinutes(1); + var h = new Harness(); + h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn)) + { + ["WhatsNewSeenAgentUtc"] = existing + }); + + await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "agent", newer); + + var (entity, mode) = Assert.Single(h.Upserts); + Assert.Equal(TableUpdateMode.Merge, mode); + Assert.Equal(Upn, entity.GetString("Upn")); + Assert.Equal(newer, entity.GetDateTime("WhatsNewSeenAgentUtc")); + Assert.False(entity.ContainsKey("LastSeen")); + Assert.False(entity.ContainsKey("UserRole")); + Assert.False(entity.ContainsKey("WhatsNewSeenPlatformUtc")); + } + + [Fact] + public async Task MarkUserWhatsNewSeen_MissingRow_MergeCreatesRowWithUpn() + { + var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + var h = new Harness(); + h.Table.Setup(t => t.GetEntityAsync( + TenantId, + TableStorageService.PresenceRowKey(Upn), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new RequestFailedException(404, "Not found")); + + await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", seen); + + var (entity, mode) = Assert.Single(h.Upserts); + Assert.Equal(TableUpdateMode.Merge, mode); + Assert.Equal(TenantId, entity.PartitionKey); + Assert.Equal(TableStorageService.PresenceRowKey(Upn), entity.RowKey); + Assert.Equal(Upn, entity.GetString("Upn")); + Assert.Equal(seen, entity.GetDateTime("WhatsNewSeenPlatformUtc")); + Assert.False(entity.ContainsKey("LastSeen")); + Assert.False(entity.ContainsKey("UserRole")); + } + + private sealed class Harness + { + public Mock Table { get; } = new(); + public TableStorageService Sut { get; } + public List<(TableEntity Entity, TableUpdateMode Mode)> Upserts { get; } = new(); + + public Harness() + { + Table.Setup(t => t.UpsertEntityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((e, mode, _) => + { + Upserts.Add((e, mode)); + return Task.FromResult(Mock.Of()); + }); + + var serviceClient = new Mock(); + serviceClient.Setup(c => c.GetTableClient(Constants.TableNames.UserPresence)).Returns(Table.Object); + Sut = new TableStorageService(serviceClient.Object, NullLogger.Instance); + } + + public void SetupRead(TableEntity entity) + { + Table.Setup(t => t.GetEntityAsync( + entity.PartitionKey, + entity.RowKey, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(Response.FromValue(entity, Mock.Of())); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs index c6ca4427..057758d1 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -92,6 +92,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole) => _storage.RecordUserPresenceAsync(tenantId, upn, userRole); + public Task GetUserWhatsNewSeenAsync(string tenantId, string upn) + => _storage.GetUserWhatsNewSeenAsync(tenantId, upn); + + public Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc) + => _storage.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc); + public Task> GetActivePresenceAsync(TimeSpan window) => _storage.GetActivePresenceAsync(window); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index b09bcda9..be36add3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -100,7 +100,8 @@ public class AuthFunction // --- Parallel data fetch: all independent queries run concurrently --- // Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500. - // This is intentional — all 6 queries are required for the auth decision. + // This is intentional — the auth-decision queries are required; the What's-new marker read + // is fail-soft inside the repository because it is only a UX convenience. // Non-creating read: do NOT auto-persist a default config row just because someone authenticated. // A delegated-only external MSP login must not phantom-onboard its home tenant (see the side-effect // gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync. @@ -116,9 +117,10 @@ public class AuthFunction // middleware does (the table-vs-claim reconciliation happens inside the shared role resolver). var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId, principal.GetAppRoles()); var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId); + var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(tenantId, upn); await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask, - membershipTask, mcpCheckTask, existingAdminsTask); + membershipTask, mcpCheckTask, existingAdminsTask, whatsNewSeenTask); var (tenantConfig, _) = tenantConfigTask.Result; var globalRole = globalRoleTask.Result; @@ -131,6 +133,7 @@ public class AuthFunction var (tableState, tableRole) = membershipTask.Result; var mcpCheck = mcpCheckTask.Result; var existingAdmins = existingAdminsTask.Result; + var whatsNewSeen = whatsNewSeenTask.Result; // Reconcile the TenantAdmins table state with any Entra app-role claim. An enabled row // wins; a disabled row is an explicit deny (claim ignored); only a missing row falls back @@ -164,7 +167,9 @@ public class AuthFunction memberRole, mcpCheck, existingAdmins.Count > 0, tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty, delegatedTenantIds, - homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary"); + homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary", + whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc, + whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc); if (!decision.IsSuccess) { @@ -193,6 +198,35 @@ public class AuthFunction return response; } + /// + /// PUT /api/auth/me/whats-new-seen + /// Marks the caller's What's-new channel as seen. + /// + [Function("MarkWhatsNewSeen")] + [Authorize] + public async Task MarkWhatsNewSeen( + [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "auth/me/whats-new-seen")] HttpRequestData req, + FunctionContext context) + { + var principal = context.GetUser(); + if (principal == null) + return req.CreateResponse(HttpStatusCode.Unauthorized); + + var tenantId = principal.GetTenantId(); + var upn = principal.GetUserPrincipalName(); + if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn)) + return await req.BadRequestAsync("Missing required claims"); + + var read = await req.ReadAsync(); + if (read.Error != null) return read.Error; + + if (!TryNormalizeWhatsNewSeen(read.Value, DateTime.UtcNow, out var channel, out var seenUtc, out var error)) + return await req.BadRequestAsync(error); + + await _metricsRepo.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc); + return req.CreateResponse(HttpStatusCode.NoContent); + } + /// /// GET /api/auth/is-global-admin /// Checks if the current user is a Global Admin @@ -515,7 +549,9 @@ public class AuthFunction bool hasTenantAdmins, string tenantId, string upn, string displayName, string objectId, IReadOnlyCollection? delegatedTenantIds = null, - string homedApp = "primary") + string homedApp = "primary", + DateTime? whatsNewSeenPlatformUtc = null, + DateTime? whatsNewSeenAgentUtc = null) { // A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so — // like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant @@ -603,10 +639,61 @@ public class AuthFunction BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow), UnrestrictedModeEnabled = FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable - && tenantConfig.UnrestrictedModeEnabled + && tenantConfig.UnrestrictedModeEnabled, + WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc, + WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc }, needsAutoAdmin); } + internal static bool TryNormalizeWhatsNewSeen( + WhatsNewSeenRequest? body, + DateTime nowUtc, + out string channel, + out DateTime seenUtc, + out string error) + { + channel = string.Empty; + seenUtc = default; + error = string.Empty; + + if (body == null) + { + error = "Request body is required"; + return false; + } + + channel = (body.Channel ?? string.Empty).Trim().ToLowerInvariant(); + if (channel is not ("platform" or "agent")) + { + error = "channel must be 'platform' or 'agent'"; + return false; + } + + if (body.SeenUtc == default) + { + error = "seenUtc is required"; + return false; + } + + nowUtc = NormalizeUtc(nowUtc); + seenUtc = NormalizeUtc(body.SeenUtc); + if (seenUtc > nowUtc.AddMinutes(5)) + { + // Client clocks can run ahead; clamp rather than reject so the marker remains harmless. + seenUtc = nowUtc; + } + + return true; + } + + private static DateTime NormalizeUtc(DateTime value) + => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + /// /// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com) /// diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index de48e55b..19d48ddb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -209,6 +209,7 @@ public static class EndpointAccessPolicyCatalog // ── AuthenticatedUser ─────────────────────────────────────────── new("GET", "auth/me", EndpointPolicy.AuthenticatedUser), + new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser), new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser), new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser), // Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index b0668850..9db815c0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -1091,7 +1091,7 @@ namespace AutopilotMonitor.Functions.Services /// /// Upserts a single presence row (PK=tenantId, RK=hash(UPN)) stamped with LastSeen=now. - /// One row per user — overwritten on every call, so the table never grows past the distinct-user count. + /// One row per user, so the table never grows past the distinct-user count. /// public async Task RecordUserPresenceAsync(string tenantId, string upn, string userRole) { @@ -1105,7 +1105,11 @@ namespace AutopilotMonitor.Functions.Services ["LastSeen"] = DateTime.UtcNow }; - await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + // Merge preserves the What's-new seen marks carried on the same row + // (WhatsNewSeenPlatformUtc / WhatsNewSeenAgentUtc); a Replace upsert every + // ≥60s would wipe them. Upn/UserRole/LastSeen are always set here, so Merge + // is semantically identical for presence. + await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Merge); } catch (Exception ex) { @@ -1114,6 +1118,89 @@ namespace AutopilotMonitor.Functions.Services } } + public async Task GetUserWhatsNewSeenAsync(string tenantId, string upn) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence); + var entity = (await tableClient.GetEntityAsync( + tenantId, + PresenceRowKey(upn), + select: new[] { WhatsNewSeenPlatformColumn, WhatsNewSeenAgentColumn })).Value; + + return new UserWhatsNewSeen( + entity.GetDateTime(WhatsNewSeenPlatformColumn), + entity.GetDateTime(WhatsNewSeenAgentColumn)); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return new UserWhatsNewSeen(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read What's-new seen marks for {Upn} in tenant {TenantId}", upn, tenantId); + return new UserWhatsNewSeen(); + } + } + + public async Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc) + { + var column = channel.ToLowerInvariant() switch + { + "platform" => WhatsNewSeenPlatformColumn, + "agent" => WhatsNewSeenAgentColumn, + _ => throw new ArgumentException("Unknown What's-new channel.", nameof(channel)) + }; + + seenUtc = NormalizeTableUtc(seenUtc); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence); + var rowKey = PresenceRowKey(upn); + DateTime? existing = null; + + try + { + var entity = (await tableClient.GetEntityAsync( + tenantId, + rowKey, + select: new[] { column })).Value; + existing = entity.GetDateTime(column); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Missing presence is fine: this endpoint may be the first thing that writes the row. + } + + if (existing.HasValue && NormalizeTableUtc(existing.Value) >= seenUtc) + return; + + var patch = new TableEntity(tenantId, rowKey) + { + ["Upn"] = upn ?? string.Empty, + [column] = seenUtc + }; + + await tableClient.UpsertEntityAsync(patch, TableUpdateMode.Merge); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to mark What's-new {Channel} as seen for {Upn} in tenant {TenantId}", channel, upn, tenantId); + } + } + + private const string WhatsNewSeenPlatformColumn = "WhatsNewSeenPlatformUtc"; + private const string WhatsNewSeenAgentColumn = "WhatsNewSeenAgentUtc"; + + private static DateTime NormalizeTableUtc(DateTime value) + => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + /// /// Returns all users whose LastSeen is within the given window (cross-tenant), newest first. /// Intentionally NOT wrapped in a swallow-and-return-empty try/catch: a storage failure must diff --git a/src/McpServer/autopilot-monitor-mcp/src/deployment-state.ts b/src/McpServer/autopilot-monitor-mcp/src/deployment-state.ts index 91195b59..9cff8d85 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/deployment-state.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/deployment-state.ts @@ -7,7 +7,8 @@ * mcp live in-process build info (this process IS the live deployment) * mcp manifest versions/mcp.json → … + docsCommit * web live GET {PORTAL}/version.json → { component, commit, buildUtc } - * web manifest versions/web.json → { component, commit, deployedUtc, runId } + * web manifest versions/web.json → { component, commit, deployedUtc, runId, docsCommit } + * (docsCommit = the docs revision the live What's new panel was generated from) * agent manifest {AGENT}/version.json → { version, commit, buildUtc, sha256, bootstrapVersion } * (no live endpoint — the agent runs on devices) * diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index ef5e424f..c4ea7e5f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -758,6 +758,10 @@ export interface AuthMeResponse { homedApp: string; bootstrapTokenEnabled: boolean; unrestrictedModeEnabled: boolean; + /** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */ + whatsNewSeenPlatformUtc?: string; + /** Same for the Agent tab; null = never and the key is omitted. */ + whatsNewSeenAgentUtc?: string; } export interface AutoResolveCpeMappingItem { @@ -5248,3 +5252,11 @@ export interface VerdictCalibrationTrendWindow { sessions: number; sharePct: number; } + +/** Body of PUT auth/me/whats-new-seen. */ +export interface WhatsNewSeenRequest { + /** What's-new channel: "platform" or "agent". */ + channel: string; + /** UTC timestamp when the caller viewed the channel. */ + seenUtc: string; +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs index f4130a29..0e6871c6 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -86,6 +86,10 @@ namespace AutopilotMonitor.Shared.DataAccess // --- Live Presence (one row per user, upserted) --- /// Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now. Task RecordUserPresenceAsync(string tenantId, string upn, string userRole); + /// Gets the caller's per-channel What's-new seen marks from the presence row; nulls mean never or unavailable. + Task GetUserWhatsNewSeenAsync(string tenantId, string upn); + /// Monotonically merge-upserts the caller's What's-new seen mark for one channel without touching presence fields. + Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc); /// Returns all users whose LastSeen falls within the given window, newest first (cross-tenant). Task> GetActivePresenceAsync(TimeSpan window); /// Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff (drops one-off testers). Returns the number deleted. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs index f2e03972..28e7fd6e 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs @@ -37,6 +37,20 @@ namespace AutopilotMonitor.Shared.Models public bool BootstrapTokenEnabled { get; set; } public bool UnrestrictedModeEnabled { get; set; } + + /// When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). + public DateTime? WhatsNewSeenPlatformUtc { get; set; } + /// Same for the Agent tab; null = never and the key is omitted. + public DateTime? WhatsNewSeenAgentUtc { get; set; } + } + + /// Body of PUT auth/me/whats-new-seen. + public class WhatsNewSeenRequest : IApiRequest + { + /// What's-new channel: "platform" or "agent". + public string Channel { get; set; } = default!; + /// UTC timestamp when the caller viewed the channel. + public DateTime SeenUtc { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/UserWhatsNewSeen.cs b/src/Shared/AutopilotMonitor.Shared/Models/UserWhatsNewSeen.cs new file mode 100644 index 00000000..199b631c --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/UserWhatsNewSeen.cs @@ -0,0 +1,17 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// The caller's per-channel What's-new seen marks from the UserPresence row. + public sealed class UserWhatsNewSeen + { + public UserWhatsNewSeen(DateTime? platformUtc = null, DateTime? agentUtc = null) + { + PlatformUtc = platformUtc; + AgentUtc = agentUtc; + } + + public DateTime? PlatformUtc { get; } + public DateTime? AgentUtc { get; } + } +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/ActivelyDevelopedBanner.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/ActivelyDevelopedBanner.tsx index 4cca31d6..6c622f18 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/ActivelyDevelopedBanner.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/ActivelyDevelopedBanner.tsx @@ -3,6 +3,7 @@ import { useSyncExternalStore } from "react"; import { DOCS_URL } from "@/utils/config"; import { trackEvent } from "@/lib/appInsights"; +import { useWhatsNew } from "@/hooks/useWhatsNew"; // Session-scoped dismissal: sessionStorage is per-tab, so the banner stays hidden // across reloads in this tab (service-desk monitor use case) but reappears in every @@ -30,6 +31,7 @@ const linkClassName = export function ActivelyDevelopedBanner() { const dismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const whatsNew = useWhatsNew(); if (dismissed) return null; const trackLink = (link: string) => trackEvent("dev_banner_link_clicked", { link }); @@ -43,16 +45,14 @@ export function ActivelyDevelopedBanner() { Actively developed.{" "} Autopilot Monitor recognizes a wide range of deployment scenarios and improves continuously — your reports directly shape it.{" "} - If something looks off, check the{" "} - trackLink("platform_changelog")} + onClick={() => { trackLink("whats_new"); whatsNew.open("banner", "platform"); }} > - Platform Changelog - {" "} + What's new + {" "} or{" "} {link.label} → {link.description} ))} + diff --git a/src/Web/autopilot-monitor-web/app/layout.tsx b/src/Web/autopilot-monitor-web/app/layout.tsx index 7ceb5815..a5d6e9b8 100644 --- a/src/Web/autopilot-monitor-web/app/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/layout.tsx @@ -11,6 +11,7 @@ import { ThemeProvider } from "../contexts/ThemeContext"; import Navbar from "../components/Navbar"; import ScrollToTopButton from "../components/ScrollToTopButton"; import FeedbackBubble from "../components/FeedbackBubble"; +import { WhatsNewPanelHost } from "../components/WhatsNewPanelHost"; import { SidebarProvider } from "../contexts/SidebarContext"; import { GlobalSidebar } from "../components/GlobalSidebar"; import AppInsightsInit from "../components/AppInsightsInit"; @@ -198,6 +199,7 @@ export default function RootLayout({ + diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index cb6742a8..0bb5c74b 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -16,6 +16,8 @@ import { useEditionInfo } from '@/hooks/useEditionInfo'; import GlobalSearch from './GlobalSearch'; import { DOCS_URL } from "@/utils/config"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { useWhatsNew } from "@/hooks/useWhatsNew"; +import { WhatsNewCountBadge, WhatsNewIconBadge } from "./WhatsNewPanel"; export default function Navbar() { const { isAuthenticated, user, hasGlobalScope, logout } = useAuth(); @@ -37,6 +39,7 @@ export default function Navbar() { // null until the feature-flags fetch confirms → no label flash for Pro tenants. const editionInfo = useEditionInfo(); const showCommunityTag = editionInfo?.edition === 'community'; + const whatsNew = useWhatsNew(); const notificationRef = useRef(null); const userMenuRef = useRef(null); @@ -485,10 +488,11 @@ export default function Navbar() { {/* Help Menu (?) — hidden on - {showHelp && ( @@ -506,18 +510,17 @@ export default function Navbar() { Documentation - setShowHelp(false)} + onClick={() => { setShowHelp(false); whatsNew.open("help"); }} > - Changelog - + What's new + + {showOverflow && (
@@ -641,6 +645,7 @@ export default function Navbar() { Help +
@@ -722,10 +727,11 @@ export default function Navbar() { Documentation
- { setShowOverflow(false); setOverflowSubmenu(null); }}> + { setShowOverflow(false); setOverflowSubmenu(null); }}> Service Announcements diff --git a/src/Web/autopilot-monitor-web/components/WhatsNewPanel.tsx b/src/Web/autopilot-monitor-web/components/WhatsNewPanel.tsx new file mode 100644 index 00000000..c02d4b9a --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/WhatsNewPanel.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ModalPortal } from "./ModalPortal"; +import { useWhatsNew } from "@/hooks/useWhatsNew"; +import { closeWhatsNew } from "@/lib/whatsNewStore"; +import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; +import { trackEvent } from "@/lib/appInsights"; +import { + formatBadgeCount, + unseenEntries, + WHATS_NEW_CHANNELS, + type WhatsNewChannel, + type WhatsNewEntry, + type WhatsNewSeen, +} from "@/lib/whatsNew"; + +const CHANNEL_LABEL: Record = { platform: "Platform", agent: "Agent" }; + +/** + * The red unread counter used on the navbar icons, the menu rows and the panel tabs — + * same size and cap as the notification bell's badge. + */ +export function WhatsNewCountBadge({ count, className = "" }: { count: number; className?: string }) { + if (count <= 0) return null; + return ( + + {formatBadgeCount(count)} + + ); +} + +/** The badge pinned to the top-right corner of an icon button. */ +export function WhatsNewIconBadge({ count }: { count: number }) { + if (count <= 0) return null; + return ; +} + +function BellIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ExternalIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }); + +const MARKDOWN_OPTIONS = { links: true, emphasis: true, linkClassName: "text-[var(--lp-accent-ink)] hover:underline" } as const; + +function EntryRow({ entry, isNew }: { entry: WhatsNewEntry; isNew: boolean }) { + const onLink = () => trackEvent("whats_new_link_clicked", { entryId: entry.id }); + return ( +
  • +
    + + {isNew && ( + + New + + )} +
    + {entry.title ? ( + <> +

    {formatInlineMarkdown(entry.title, { emphasis: true })}

    +

    + {formatInlineMarkdown(entry.body, MARKDOWN_OPTIONS)} +

    + + ) : ( +

    + {formatInlineMarkdown(entry.body, MARKDOWN_OPTIONS)} +

    + )} + {entry.link && ( +
    + Read update + + + )} +
  • + ); +} + +function SkeletonRows() { + return ( + + ); +} + +/** + * The What's new flyout: a floating card on the right, over the page. Rendered only while + * open (the host unmounts it), so closing leaves nothing behind. Signed-in users see "New" + * labels and tab counters against their server-side mark; anonymous visitors see the list + * with dates only. Colors come from the landing tokens (`--lp-*`), which are defined globally and follow `.dark` in the portal; + * the host passes `landing-v2` as the root class on the public site so the card stays + * light there (the class must sit INSIDE the portal — CSS variables do not cross it). + */ +export function WhatsNewPanel({ rootClassName }: { rootClassName?: string }) { + const wn = useWhatsNew(); + const closeRef = useRef(null); + // Marks as they were when the panel opened: the "New" labels must not vanish under the + // reader's eyes when the channel is marked seen a moment later. + const [seenAtOpen] = useState(() => wn.seen); + const [openedAt] = useState(() => new Date()); + + const channel = wn.panelChannel; + const entries = wn.entries(channel); + const newIds = new Set(wn.tracksUnseen ? unseenEntries(entries, seenAtOpen[channel], openedAt).map(e => e.id) : []); + + useEffect(() => { + closeRef.current?.focus(); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closeWhatsNew(); + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", onKeyDown); + }; + }, []); + + // Viewing a tab marks it seen up to the newest loaded entry. + const { status, markSeen } = wn; + useEffect(() => { + if (status !== "ready") return; + void markSeen(channel); + }, [status, channel, markSeen]); + + const switchChannel = (next: WhatsNewChannel) => { + if (next === channel) return; + trackEvent("whats_new_tab_changed", { channel: next }); + wn.setChannel(next); + }; + + const docsUrl = wn.docsUrl(channel); + + return ( + +
    + + + ); +} diff --git a/src/Web/autopilot-monitor-web/components/WhatsNewPanelHost.tsx b/src/Web/autopilot-monitor-web/components/WhatsNewPanelHost.tsx new file mode 100644 index 00000000..4863aa05 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/WhatsNewPanelHost.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; +import { WhatsNewPanel } from "./WhatsNewPanel"; +import { useWhatsNewState, openWhatsNew } from "@/lib/whatsNewStore"; +import { trackEvent } from "@/lib/appInsights"; +import { isPublicPath } from "@/lib/hostRouting"; +import { WHATS_NEW_CHANNELS, type WhatsNewChannel } from "@/lib/whatsNew"; + +/** + * Mounts the What's new panel once, in the root layout, so the portal navbar, the landing + * navbar and the Help page tile can all open the same instance through the module store. + * + * `?whats-new=platform|agent` opens it on load (release posts, LinkedIn links). + * + * The panel is styled with the landing tokens (`--lp-*`). Those are redefined by `.dark` + * globally, which is what the portal wants — but the public site is light-only and forces + * its tokens through `.landing-v2`. The panel portals to , outside that wrapper, so on + * public paths the class is re-applied here to keep the card as light as the page. + */ +export function WhatsNewPanelHost() { + const { panelOpen } = useWhatsNewState(); + const pathname = usePathname(); + const onPublicPage = isPublicPath(pathname); + + useEffect(() => { + const requested = new URLSearchParams(window.location.search).get("whats-new"); + if (requested === null) return; + const channel = (WHATS_NEW_CHANNELS as readonly string[]).includes(requested) + ? (requested as WhatsNewChannel) + : undefined; + trackEvent("whats_new_opened", { source: "deeplink", channel: channel ?? "default" }); + openWhatsNew("deeplink", channel); + }, []); + + if (!panelOpen) return null; + return ; +} diff --git a/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx b/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx index 17313148..ee7037c8 100644 --- a/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx @@ -6,6 +6,7 @@ import { useAuth } from "../../contexts/AuthContext"; import { getPortalLoginUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting"; import { DOCS_URL } from "@/utils/config"; import { BrandMark } from "../BrandMark"; +import { useWhatsNew } from "@/hooks/useWhatsNew"; // Root-anchored (/#…) so the links also work from subpages // like /get-started, /about, /terms, /privacy. @@ -25,6 +26,19 @@ function GitHubIcon({ className }: { className?: string }) { ); } +function BellIcon({ className }: { className?: string }) { + // Ringing bell: the public site announces "there is news" without claiming a count — + // anonymous visitors have no seen mark, so a counter would read 9+ for everyone. + return ( + + + + + + + ); +} + /** * Full-width landing navigation. Same auth handoff behavior as * PublicSiteNavbar: on www/apex, sign-in is delegated to the portal @@ -33,6 +47,7 @@ function GitHubIcon({ className }: { className?: string }) { export function LandingNavbar() { const { login, isAuthenticated } = useAuth(); const [menuOpen, setMenuOpen] = useState(false); + const whatsNew = useWhatsNew(); useEffect(() => { if (!menuOpen) return; @@ -102,6 +117,15 @@ export function LandingNavbar() { > +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx index a1c5a78f..769c5766 100644 --- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -263,6 +263,9 @@ function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo { hasMcpAccess: data.hasMcpAccess || false, bootstrapTokenEnabled: data.bootstrapTokenEnabled || false, unrestrictedModeEnabled: data.unrestrictedModeEnabled || false, + // What's new seen marks: absent key = never viewed (first-visit window applies). + whatsNewSeenPlatformUtc: data.whatsNewSeenPlatformUtc, + whatsNewSeenAgentUtc: data.whatsNewSeenAgentUtc, }; } diff --git a/src/Web/autopilot-monitor-web/hooks/useWhatsNew.ts b/src/Web/autopilot-monitor-web/hooks/useWhatsNew.ts new file mode 100644 index 00000000..3f455cff --- /dev/null +++ b/src/Web/autopilot-monitor-web/hooks/useWhatsNew.ts @@ -0,0 +1,132 @@ +"use client"; + +import { useCallback, useEffect, useMemo } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { api } from "@/lib/api"; +import { fetchOk, jsonBody } from "@/lib/apiClient"; +import { trackEvent } from "@/lib/appInsights"; +import { + countUnseen, + laterMark, + nextSeenMark, + WHATS_NEW_CHANNELS, + type WhatsNewChannel, + type WhatsNewEntry, + type WhatsNewSeen, +} from "@/lib/whatsNew"; +import { + closeWhatsNew, + ensureWhatsNewLoaded, + openWhatsNew, + recordSessionSeen, + setWhatsNewChannel, + useWhatsNewState, + type WhatsNewLoadStatus, +} from "@/lib/whatsNewStore"; +import type { WhatsNewSeenRequest } from "@/utils/wire-types.generated"; + +export interface UseWhatsNew { + status: WhatsNewLoadStatus; + entries: (channel: WhatsNewChannel) => WhatsNewEntry[]; + docsUrl: (channel: WhatsNewChannel) => string | null; + /** + * Whether unseen entries are counted at all — only for signed-in users, whose mark lives + * on the server. Anonymous visitors get no counter and no "New" labels. + */ + tracksUnseen: boolean; + /** Effective seen mark per channel: server mark plus this session's writes; NEVER_SEEN when not tracking. */ + seen: WhatsNewSeen; + unseen: Record; + totalUnseen: number; + panelOpen: boolean; + panelChannel: WhatsNewChannel; + open: (source: string, channel?: WhatsNewChannel) => void; + close: () => void; + setChannel: (channel: WhatsNewChannel) => void; + /** Marks a channel as viewed up to the newest loaded entry (no-op when not tracking). */ + markSeen: (channel: WhatsNewChannel) => Promise; +} + +/** + * The one hook behind every What's new trigger and the panel. Mounting it loads the payload + * (once, shared); signed-in users read their mark from auth/me and write it through the + * backend, so the counter resets across browsers. + */ +export function useWhatsNew(): UseWhatsNew { + const { isAuthenticated, user, getAccessToken } = useAuth(); + const s = useWhatsNewState(); + + useEffect(() => { + void ensureWhatsNewLoaded(); + }, []); + + const tracksUnseen = isAuthenticated && user !== null; + const serverPlatform = tracksUnseen ? user.whatsNewSeenPlatformUtc ?? null : null; + const serverAgent = tracksUnseen ? user.whatsNewSeenAgentUtc ?? null : null; + const { sessionSeen } = s; + const seen = useMemo( + () => ({ + platform: laterMark(serverPlatform, sessionSeen.platform), + agent: laterMark(serverAgent, sessionSeen.agent), + }), + [serverPlatform, serverAgent, sessionSeen], + ); + + const entries = useCallback( + (channel: WhatsNewChannel): WhatsNewEntry[] => s.payload?.channels[channel].entries ?? [], + [s.payload], + ); + + const unseen = { + platform: tracksUnseen ? countUnseen(entries("platform"), seen.platform) : 0, + agent: tracksUnseen ? countUnseen(entries("agent"), seen.agent) : 0, + }; + + const markSeen = useCallback( + async (channel: WhatsNewChannel) => { + if (!tracksUnseen) return; + const mark = nextSeenMark(entries(channel)); + if (mark === null) return; + if (laterMark(seen[channel], mark) === seen[channel]) return; + recordSessionSeen(channel, mark); + try { + await fetchOk(api.auth.whatsNewSeen(), getAccessToken, { + method: "PUT", + body: jsonBody({ channel, seenUtc: mark }), + }); + } catch { + // Best effort: the session layer already hides the badge; the next visit retries. + } + }, + [entries, seen, tracksUnseen, getAccessToken], + ); + + const open = useCallback( + (source: string, channel?: WhatsNewChannel) => { + trackEvent("whats_new_opened", { + source, + authenticated: isAuthenticated, + unseenPlatform: unseen.platform, + unseenAgent: unseen.agent, + }); + openWhatsNew(source, channel); + }, + [isAuthenticated, unseen.platform, unseen.agent], + ); + + return { + status: s.status, + entries, + docsUrl: channel => s.payload?.channels[channel].docsUrl ?? null, + tracksUnseen, + seen, + unseen, + totalUnseen: WHATS_NEW_CHANNELS.reduce((sum, c) => sum + unseen[c], 0), + panelOpen: s.panelOpen, + panelChannel: s.panelChannel, + open, + close: closeWhatsNew, + setChannel: setWhatsNewChannel, + markSeen, + }; +} diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/formatInlineMarkdown.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/formatInlineMarkdown.test.ts new file mode 100644 index 00000000..68248c4c --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/formatInlineMarkdown.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import React from "react"; +import { formatInlineMarkdown } from "../formatInlineMarkdown"; + +function tag(node: React.ReactNode): string | null { + return React.isValidElement(node) ? String(node.type) : null; +} +function props(node: React.ReactNode): Record { + return React.isValidElement(node) ? (node.props as Record) : {}; +} + +describe("formatInlineMarkdown", () => { + it("renders bold and code, leaves links as text by default (rule explanations stay unchanged)", () => { + const out = formatInlineMarkdown("A **b** `c` [d](https://e)"); + expect(out.map(tag)).toEqual([null, "strong", null, "code", null]); + expect(out[4]).toBe(" [d](https://e)"); + }); + + it("renders https links as new-tab anchors when opted in", () => { + const out = formatInlineMarkdown("See [Docs](https://docs.example/p#a) now", { links: true, linkClassName: "x" }); + expect(out.map(tag)).toEqual([null, "a", null]); + expect(props(out[1])).toMatchObject({ href: "https://docs.example/p#a", target: "_blank", rel: "noopener noreferrer", className: "x", children: "Docs" }); + }); + + it("degrades non-http link targets to their label", () => { + const out = formatInlineMarkdown("[x](javascript:void) and [y](mailto:a@b)", { links: true }); + expect(out.map(tag)).toEqual([null, null, null, null, null]); + expect(out.join("")).toBe("x and y"); + }); + + it("mixes links with bold and code", () => { + const out = formatInlineMarkdown("**T** — `--flag` then [L](https://l)", { links: true }); + expect(out.map(tag)).toEqual([null, "strong", null, "code", null, "a", null]); + }); + + it("renders single-asterisk emphasis only when opted in, never inside bold", () => { + expect(formatInlineMarkdown("mapped to *Soft reboot* or **Hard**").map(tag)).toEqual([null, "strong", null]); + const out = formatInlineMarkdown("mapped to *Soft reboot* or **Hard**", { emphasis: true }); + expect(out.map(tag)).toEqual([null, "em", null, "strong", null]); + expect(props(out[1]).children).toBe("Soft reboot"); + expect(formatInlineMarkdown("a * b * c", { emphasis: true }).map(tag)).toEqual([null]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/whatsNew.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/whatsNew.test.ts new file mode 100644 index 00000000..8f6e6786 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/whatsNew.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + countUnseen, + FIRST_VISIT_WINDOW_DAYS, + formatBadgeCount, + laterMark, + nextSeenMark, + parseWhatsNewPayload, + unseenEntries, + type WhatsNewEntry, +} from "../whatsNew"; + +const NOW = new Date("2026-09-08T12:00:00.000Z"); + +function entry(id: string, addedUtc: string): WhatsNewEntry { + return { id, addedUtc, period: "September 2026", title: null, body: id, link: null }; +} + +const ENTRIES = [ + entry("a", "2026-09-07T10:00:00.000Z"), + entry("b", "2026-09-01T10:00:00.000Z"), + entry("c", "2026-08-01T10:00:00.000Z"), // older than the first-visit window +]; + +describe("unseen logic", () => { + it("counts entries newer than the seen mark", () => { + expect(unseenEntries(ENTRIES, "2026-09-01T10:00:00.000Z", NOW).map(e => e.id)).toEqual(["a"]); + expect(countUnseen(ENTRIES, "2026-09-07T10:00:00.000Z", NOW)).toBe(0); + }); + + it("falls back to the first-visit window without a mark", () => { + expect(unseenEntries(ENTRIES, null, NOW).map(e => e.id)).toEqual(["a", "b"]); + const edge = new Date(NOW.getTime() - FIRST_VISIT_WINDOW_DAYS * 86_400_000).toISOString(); + expect(countUnseen([entry("x", edge)], null, NOW)).toBe(0); + }); + + it("treats an unparsable mark like no mark", () => { + expect(countUnseen(ENTRIES, "garbage", NOW)).toBe(2); + }); + + it("uses the newest loaded entry as the next mark, never now", () => { + expect(nextSeenMark(ENTRIES)).toBe("2026-09-07T10:00:00.000Z"); + expect(nextSeenMark([])).toBeNull(); + }); + + it("keeps marks monotonic", () => { + expect(laterMark(null, "2026-09-01T00:00:00Z")).toBe("2026-09-01T00:00:00Z"); + expect(laterMark("2026-09-02T00:00:00Z", "2026-09-01T00:00:00Z")).toBe("2026-09-02T00:00:00Z"); + expect(laterMark("2026-09-01T00:00:00Z", "2026-09-02T00:00:00Z")).toBe("2026-09-02T00:00:00Z"); + }); + + it("caps the badge like the notification bell", () => { + expect(formatBadgeCount(3)).toBe("3"); + expect(formatBadgeCount(9)).toBe("9"); + expect(formatBadgeCount(10)).toBe("9+"); + }); +}); + +describe("parseWhatsNewPayload", () => { + const valid = { + schemaVersion: 1, + generatedUtc: "2026-09-08T09:00:00.000Z", + docsCommit: "abc1234", + channels: { + platform: { docsUrl: "https://d/p", entries: [ENTRIES[0]] }, + agent: { docsUrl: "https://d/a", entries: [] }, + }, + }; + + it("accepts the generated shape", () => { + expect(parseWhatsNewPayload(valid)).not.toBeNull(); + expect(parseWhatsNewPayload({ ...valid, docsCommit: null })).not.toBeNull(); + }); + + it("rejects wrong schema versions, missing channels and bad entries", () => { + expect(parseWhatsNewPayload({ ...valid, schemaVersion: 2 })).toBeNull(); + expect(parseWhatsNewPayload({ ...valid, channels: { platform: valid.channels.platform } })).toBeNull(); + expect( + parseWhatsNewPayload({ + ...valid, + channels: { ...valid.channels, platform: { docsUrl: "x", entries: [{ id: "a" }] } }, + }), + ).toBeNull(); + expect( + parseWhatsNewPayload({ + ...valid, + channels: { ...valid.channels, platform: { docsUrl: "x", entries: [entry("a", "not-a-date")] } }, + }), + ).toBeNull(); + expect(parseWhatsNewPayload(null)).toBeNull(); + expect(parseWhatsNewPayload("string")).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 39e8c26f..962e8f14 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -18,6 +18,7 @@ export const api = { // ── Auth ────────────────────────────────────────────────────────────────── auth: { me: () => `${API_BASE_URL}/api/auth/me`, + whatsNewSeen: () => `${API_BASE_URL}/api/auth/me/whats-new-seen`, }, // ── Sessions ────────────────────────────────────────────────────────────── diff --git a/src/Web/autopilot-monitor-web/lib/docsPaths.ts b/src/Web/autopilot-monitor-web/lib/docsPaths.ts index b52211dd..e400b7a1 100644 --- a/src/Web/autopilot-monitor-web/lib/docsPaths.ts +++ b/src/Web/autopilot-monitor-web/lib/docsPaths.ts @@ -45,8 +45,9 @@ export const DOCS_PATHS = { usageMetrics: "/portal-guide/usage-metrics", auditLog: "/portal-guide/audit-log-and-system-health#audit-log", systemHealth: "/portal-guide/audit-log-and-system-health#system-health", - // Navbar help menu + // Navbar help menu / What's new panel platformChangelog: "/changelog/platform-changelog", + agentChangelog: "/changelog/agent-changelog", serviceAnnouncements: "/troubleshooting-and-support/service-announcements", // Dual app-registration window (sign-in banners, failed-sign-in screen) appRegistrationMigration: "/troubleshooting-and-support/app-registration-migration", diff --git a/src/Web/autopilot-monitor-web/lib/formatInlineMarkdown.tsx b/src/Web/autopilot-monitor-web/lib/formatInlineMarkdown.tsx index be684187..821e2fe2 100644 --- a/src/Web/autopilot-monitor-web/lib/formatInlineMarkdown.tsx +++ b/src/Web/autopilot-monitor-web/lib/formatInlineMarkdown.tsx @@ -1,12 +1,35 @@ import React from "react"; +export interface InlineMarkdownOptions { + /** + * Render `[label](https://…)` as links opening in a new tab. Off by default: rule + * explanations never carried links, and their callers must stay pixel-identical. + * Only http(s) targets become anchors; anything else renders as its label. + */ + links?: boolean; + linkClassName?: string; + /** Render single-asterisk `*emphasis*` as . Off by default for the same reason. */ + emphasis?: boolean; +} + +const BOLD_OR_CODE = "\\*\\*[^*]+\\*\\*|`[^`]+`"; +const LINK = "\\[[^\\]]+\\]\\([^)\\s]+\\)"; +const EMPHASIS = "(? { if (part.startsWith("**") && part.endsWith("**")) { @@ -26,6 +49,21 @@ export function formatInlineMarkdown(text: string): React.ReactNode[] { ); } + if (options.links && part.startsWith("[")) { + const m = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part); + if (m) { + const [, label, href] = m; + if (!/^https?:\/\//i.test(href)) return label; + return ( + + {label} + + ); + } + } + if (options.emphasis && part.length > 2 && part.startsWith("*") && part.endsWith("*")) { + return {part.slice(1, -1)}; + } return part; }); } diff --git a/src/Web/autopilot-monitor-web/lib/whatsNew.ts b/src/Web/autopilot-monitor-web/lib/whatsNew.ts new file mode 100644 index 00000000..b9b9e30e --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/whatsNew.ts @@ -0,0 +1,136 @@ +/** + * "What's new" — the portal's rendering of the two customer changelogs. + * + * Data: /whats-new.json, generated at build time from the docs repo by + * scripts/whats-new/build-whats-new.js. The docs markdown stays the only authoritative + * text; this module only types the payload and counts what a signed-in user has not seen + * yet. Anonymous visitors on the public site get the panel without a counter: they have no + * identity to attach a seen mark to, and a counter that reads 9+ for every first visit would + * claim urgency nobody missed. + */ + +export const WHATS_NEW_URL = "/whats-new.json"; +export const WHATS_NEW_CHANNELS = ["platform", "agent"] as const; +export type WhatsNewChannel = (typeof WHATS_NEW_CHANNELS)[number]; + +/** First visit of a signed-in user (no mark yet): entries younger than this count as unseen. */ +export const FIRST_VISIT_WINDOW_DAYS = 30; + +export interface WhatsNewEntry { + id: string; + /** ISO timestamp — when the bullet was committed to the docs repo. */ + addedUtc: string; + /** The `## ` heading the bullet lives under, e.g. "September 2026". */ + period: string; + /** Platform bullets carry a bold lead; agent bullets are a single sentence (null). */ + title: string | null; + /** Inline markdown: **bold**, `code`, [label](https://…). */ + body: string; + /** First docs link of the bullet — the "Read update" target — or null. */ + link: string | null; +} + +export interface WhatsNewChannelData { + docsUrl: string; + entries: WhatsNewEntry[]; +} + +export interface WhatsNewPayload { + schemaVersion: 1; + generatedUtc: string; + docsCommit: string | null; + channels: Record; +} + +/** ISO timestamp per channel of the newest entry the user has seen; null = never. */ +export type WhatsNewSeen = Record; + +export const NEVER_SEEN: WhatsNewSeen = { platform: null, agent: null }; + +// ── Payload guard ──────────────────────────────────────────── + +function isEntry(value: unknown): value is WhatsNewEntry { + if (!value || typeof value !== "object") return false; + const e = value as Record; + return ( + typeof e.id === "string" && + typeof e.addedUtc === "string" && + !Number.isNaN(Date.parse(e.addedUtc)) && + typeof e.period === "string" && + (e.title === null || typeof e.title === "string") && + typeof e.body === "string" && + (e.link === null || typeof e.link === "string") + ); +} + +function isChannel(value: unknown): value is WhatsNewChannelData { + if (!value || typeof value !== "object") return false; + const c = value as Record; + return ( + typeof c.docsUrl === "string" && + Array.isArray(c.entries) && + c.entries.every(isEntry) + ); +} + +/** + * Validates the fetched JSON. Anything off-shape yields null — the panel then shows + * its docs links instead of half-rendered content, and no badge is computed from it. + */ +export function parseWhatsNewPayload(value: unknown): WhatsNewPayload | null { + if (!value || typeof value !== "object") return null; + const p = value as Record; + if (p.schemaVersion !== 1) return null; + if (typeof p.generatedUtc !== "string") return null; + if (!(p.docsCommit === null || typeof p.docsCommit === "string")) return null; + const channels = p.channels as Record | undefined; + if (!channels || typeof channels !== "object") return null; + for (const name of WHATS_NEW_CHANNELS) { + if (!isChannel(channels[name])) return null; + } + return value as WhatsNewPayload; +} + +// ── Unseen logic ───────────────────────────────────────────── + +/** + * Entries newer than the seen mark. Without a mark (first visit, or a browser before the + * backend deploy) the last FIRST_VISIT_WINDOW_DAYS count — bounded, but enough to show + * how often the platform moves. + */ +export function unseenEntries(entries: WhatsNewEntry[], seenUtc: string | null, now: Date = new Date()): WhatsNewEntry[] { + const threshold = + seenUtc !== null && !Number.isNaN(Date.parse(seenUtc)) + ? Date.parse(seenUtc) + : now.getTime() - FIRST_VISIT_WINDOW_DAYS * 86_400_000; + return entries.filter(e => Date.parse(e.addedUtc) > threshold); +} + +export function countUnseen(entries: WhatsNewEntry[], seenUtc: string | null, now: Date = new Date()): number { + return unseenEntries(entries, seenUtc, now).length; +} + +/** + * The mark to store once a channel has been viewed: the newest `addedUtc` among the + * LOADED entries — not `now`. A bullet committed before the web deploy that carried + * it would otherwise be marked seen without ever having been shown. + */ +export function nextSeenMark(entries: WhatsNewEntry[]): string | null { + let max: string | null = null; + for (const e of entries) { + if (max === null || Date.parse(e.addedUtc) > Date.parse(max)) max = e.addedUtc; + } + return max; +} + +/** Later of two ISO timestamps; null-safe. Keeps a mark monotonic across tabs. */ +export function laterMark(a: string | null, b: string | null): string | null { + if (a === null) return b; + if (b === null) return a; + return Date.parse(b) > Date.parse(a) ? b : a; +} + +/** "9+" cap identical to the notification bell. */ +export function formatBadgeCount(n: number): string { + return n > 9 ? "9+" : String(n); +} diff --git a/src/Web/autopilot-monitor-web/lib/whatsNewStore.ts b/src/Web/autopilot-monitor-web/lib/whatsNewStore.ts new file mode 100644 index 00000000..c0cfbb1d --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/whatsNewStore.ts @@ -0,0 +1,130 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { parseJsonBody } from "@/lib/apiClient"; +import { + laterMark, + NEVER_SEEN, + parseWhatsNewPayload, + WHATS_NEW_URL, + type WhatsNewChannel, + type WhatsNewPayload, + type WhatsNewSeen, +} from "@/lib/whatsNew"; + +/** + * Module-level store for What's new: the fetched payload, the panel's open state and the + * marks written in this browser session. One store instead of a context because three + * unrelated triggers open the same panel — the portal navbar, the landing navbar and the + * Help page tile — and the panel itself is mounted once in the root layout. + * + * Snapshot is a single immutable object; the server snapshot is the initial state so the + * badge only appears after hydration (no markup mismatch for the prerendered pages). + */ + +export type WhatsNewLoadStatus = "idle" | "loading" | "ready" | "error"; + +export interface WhatsNewState { + status: WhatsNewLoadStatus; + payload: WhatsNewPayload | null; + fetchedAt: number; + /** Marks written in this session, layered over the server marks (optimistic). */ + sessionSeen: WhatsNewSeen; + panelOpen: boolean; + panelChannel: WhatsNewChannel; + panelSource: string; +} + +const INITIAL: WhatsNewState = { + status: "idle", + payload: null, + fetchedAt: 0, + sessionSeen: NEVER_SEEN, + panelOpen: false, + panelChannel: "platform", + panelSource: "", +}; + +/** Re-fetch when the tab comes back after this long — a deploy may have shipped new entries. */ +const STALE_AFTER_MS = 30 * 60 * 1000; + +let state: WhatsNewState = INITIAL; +const listeners = new Set<() => void>(); +let inflight: Promise | null = null; +let visibilityHooked = false; + +function setState(patch: Partial): void { + state = { ...state, ...patch }; + for (const listener of [...listeners]) listener(); +} + +function subscribe(onStoreChange: () => void): () => void { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); +} + +export function useWhatsNewState(): WhatsNewState { + return useSyncExternalStore(subscribe, () => state, () => INITIAL); +} + +/** Loads the payload once (and again when stale); safe to call from every trigger's mount. */ +export function ensureWhatsNewLoaded(): Promise { + if (typeof window === "undefined") return Promise.resolve(); + if (inflight) return inflight; + + if (!visibilityHooked) { + visibilityHooked = true; + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") void ensureWhatsNewLoaded(); + }); + } + + const fresh = state.status === "ready" && Date.now() - state.fetchedAt < STALE_AFTER_MS; + if (fresh) return Promise.resolve(); + + setState({ status: state.payload ? state.status : "loading" }); + inflight = (async () => { + try { + const response = await fetch(WHATS_NEW_URL, { cache: "no-store" }); + if (!response.ok) throw new Error(`whats-new.json ${response.status}`); + const payload = parseWhatsNewPayload(await parseJsonBody(response)); + if (!payload) throw new Error("whats-new.json: unexpected shape"); + setState({ status: "ready", payload, fetchedAt: Date.now() }); + } catch { + // Keep a previously loaded payload; only a first load surfaces the error state. + setState({ status: state.payload ? "ready" : "error", fetchedAt: Date.now() }); + } finally { + inflight = null; + } + })(); + return inflight; +} + +export function openWhatsNew(source: string, channel?: WhatsNewChannel): void { + setState({ panelOpen: true, panelSource: source, ...(channel ? { panelChannel: channel } : {}) }); + void ensureWhatsNewLoaded(); +} + +export function closeWhatsNew(): void { + setState({ panelOpen: false }); +} + +export function setWhatsNewChannel(channel: WhatsNewChannel): void { + setState({ panelChannel: channel }); +} + +/** + * Records a mark for this session so the badge updates immediately; the hook persists it + * server-side. Monotonic: an older mark never wins. + */ +export function recordSessionSeen(channel: WhatsNewChannel, seenUtc: string): void { + const next = laterMark(state.sessionSeen[channel], seenUtc); + if (next === state.sessionSeen[channel]) return; + setState({ sessionSeen: { ...state.sessionSeen, [channel]: next } }); +} + +/** Test seam. */ +export function resetWhatsNewStoreForTests(): void { + state = INITIAL; + inflight = null; +} diff --git a/src/Web/autopilot-monitor-web/package.json b/src/Web/autopilot-monitor-web/package.json index 75d73b51..0c82ac2b 100644 --- a/src/Web/autopilot-monitor-web/package.json +++ b/src/Web/autopilot-monitor-web/package.json @@ -7,8 +7,9 @@ }, "scripts": { "dev": "next dev", - "prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js && node scripts/generate-version.js", + "prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js && node scripts/generate-version.js && node scripts/whats-new/build-whats-new.js", "sync-error-codes": "node scripts/sync-error-codes.js", + "whats-new": "node scripts/whats-new/build-whats-new.js", "generate:manifests": "node scripts/generate-shared-manifest-types.js", "build": "next build", "start": "next start", diff --git a/src/Web/autopilot-monitor-web/scripts/whats-new/__tests__/whats-new-core.test.ts b/src/Web/autopilot-monitor-web/scripts/whats-new/__tests__/whats-new-core.test.ts new file mode 100644 index 00000000..cc92c4c5 --- /dev/null +++ b/src/Web/autopilot-monitor-web/scripts/whats-new/__tests__/whats-new-core.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; +import core from "../whats-new-core.js"; + +const { + parseChangelog, + splitTitle, + slugifyHeading, + buildSummaryUrlMap, + resolveLink, + rewriteLinks, + parseBlamePorcelain, + stableId, + buildPayload, + emptyPayload, +} = core; + +// Synthetic docs bundle — never copied from the real changelog, so the test does not +// silently drift with the content it is meant to exercise. +const SUMMARY = [ + "# Table of contents", + "", + "* [Welcome](README.md)", + "* [Plans](plans.md)", + "", + "## Portal Guide", + "", + "* [Dashboard & Sessions](portal-guide/dashboard-and-sessions.md)", + "", + "## Rules", + "", + "* [Analyze Rules](rules/analyze-rules/README.md)", + " * [Concepts](rules/analyze-rules/concepts.md)", + "", + "## Trust & Security", + "", + "* [Security FAQ](trust/security-faq.md)", + "", + "## Troubleshooting & Support", + "", + "* [FAQ](troubleshooting/faq.md)", + "* [How to Purchase](troubleshooting-and-support/how-to-purchase/README.md)", + " * [Marketplace](troubleshooting-and-support/how-to-purchase/microsoft-marketplace.md)", + "", + "## Changelog", + "", + "* [Agent Changelog](changelog/agent-changelog.md)", + "* [Platform Changelog](changelog/platform-changelog.md)", +].join("\n"); + +const PLATFORM_MD = [ + "---", + "type: Changelog", + "description: >-", + " Two-line frontmatter,", + " newest first.", + "---", + "", + "# Platform Changelog", + "", + "Intro paragraph with a * star that is not a bullet.", + "", + "## September 2026", + "", + "* **Filter widgets by priority** — Every chip filters the list. See [Dashboard](../portal-guide/dashboard-and-sessions.md#search-syntax) and [Concepts](../rules/analyze-rules/concepts.md).", + "* **Wrapped bullet** — First line", + " continues here with `code` and a [release](https://github.com/okieselbach/AutopilotMonitor/releases).", + "* **Unknown page** — Links to [somewhere](../troubleshooting/new-page.md) and [outside](http://insecure.example).", + "", + "## August 2026", + "", + "* **Older** — Older body. See [FAQ](../troubleshooting/faq.md).", + "", + "## July 2026", + "", + "* **July** — July body.", + "", + "## June 2026", + "", + "* **Dropped** — Outside the three-period window.", +].join("\r\n"); + +const AGENT_MD = [ + "# Agent Changelog", + "", + "**Current versions:** ![badge](https://img.shields.io/x)", + "", + "## September 2026", + "", + "* Diagnostics upload events now report how many files the package skipped", + "* Keep-awake no longer releases early (see the [platform changelog](platform-changelog.md))", +].join("\n"); + +function blameFor(lines: Record): string { + // Minimal --line-porcelain shape: header, author-time, tab-prefixed content. + return Object.entries(lines) + .map(([line, time]) => { + const hash = time === null ? "0".repeat(40) : "a".repeat(40); + return [`${hash} ${line} ${line} 1`, ...(time === null ? [] : [`author-time ${time}`]), "\tcontent"].join("\n"); + }) + .join("\n"); +} + +describe("parseChangelog", () => { + it("skips frontmatter and intro, keeps blocks in file order, folds continuation lines", () => { + const blocks = parseChangelog(PLATFORM_MD); + expect(blocks.map(b => b.period)).toEqual(["September 2026", "August 2026", "July 2026", "June 2026"]); + expect(blocks[0].bullets).toHaveLength(3); + expect(blocks[0].bullets[1].text).toBe( + "**Wrapped bullet** — First line continues here with `code` and a [release](https://github.com/okieselbach/AutopilotMonitor/releases).", + ); + }); + + it("records the 1-based source line of each bullet's first line (CRLF input)", () => { + const blocks = parseChangelog(PLATFORM_MD); + expect(blocks[0].bullets.map(b => b.line)).toEqual([14, 15, 17]); + }); + + it("ignores the agent badge line and reads sentence bullets", () => { + const blocks = parseChangelog(AGENT_MD); + expect(blocks).toHaveLength(1); + expect(blocks[0].bullets).toHaveLength(2); + }); +}); + +describe("splitTitle", () => { + it("splits the platform lead at the em dash", () => { + expect(splitTitle("**Title here** — Body text.")).toEqual({ title: "Title here", body: "Body text." }); + }); + it("returns null title for a plain sentence", () => { + expect(splitTitle("Agent reports something")).toEqual({ title: null, body: "Agent reports something" }); + }); + it("does not treat a bold word inside the sentence as a title", () => { + expect(splitTitle("Now **bold** inside").title).toBeNull(); + }); +}); + +describe("GitBook URL map", () => { + const map = buildSummaryUrlMap(SUMMARY); + + it("slugifies group headings the way GitBook does", () => { + expect(slugifyHeading("Troubleshooting & Support")).toBe("troubleshooting-and-support"); + expect(slugifyHeading("Trust & Security")).toBe("trust-and-security"); + expect(slugifyHeading("Getting Started")).toBe("getting-started"); + }); + + it("maps root pages, grouped pages, README folders and nested pages", () => { + expect(map.get("README.md")).toBe("/"); + expect(map.get("plans.md")).toBe("/plans"); + expect(map.get("portal-guide/dashboard-and-sessions.md")).toBe("/portal-guide/dashboard-and-sessions"); + expect(map.get("troubleshooting/faq.md")).toBe("/troubleshooting-and-support/faq"); + expect(map.get("rules/analyze-rules/README.md")).toBe("/rules/analyze-rules"); + expect(map.get("rules/analyze-rules/concepts.md")).toBe("/rules/analyze-rules/concepts"); + expect(map.get("troubleshooting-and-support/how-to-purchase/microsoft-marketplace.md")).toBe( + "/troubleshooting-and-support/how-to-purchase/microsoft-marketplace", + ); + expect(map.get("changelog/platform-changelog.md")).toBe("/changelog/platform-changelog"); + }); +}); + +describe("resolveLink / rewriteLinks", () => { + const urlMap = buildSummaryUrlMap(SUMMARY); + const folderSlugMap = new Map([["troubleshooting", "troubleshooting-and-support"]]); + const ctx = { docsUrl: "https://docs.example", fromDir: "changelog", urlMap, folderSlugMap }; + + it("resolves relative docs links with anchors and sibling links", () => { + expect(resolveLink("../portal-guide/dashboard-and-sessions.md#search-syntax", ctx)).toBe( + "https://docs.example/portal-guide/dashboard-and-sessions#search-syntax", + ); + expect(resolveLink("platform-changelog.md", ctx)).toBe("https://docs.example/changelog/platform-changelog"); + }); + + it("passes https through, drops http and same-page anchors", () => { + expect(resolveLink("https://github.com/x/y", ctx)).toBe("https://github.com/x/y"); + expect(resolveLink("http://insecure.example", ctx)).toBeNull(); + expect(resolveLink("#anchor", ctx)).toBeNull(); + expect(resolveLink("mailto:a@b.c", ctx)).toBeNull(); + }); + + it("falls back to the folder's group slug for pages missing from SUMMARY", () => { + expect(resolveLink("../troubleshooting/new-page.md", ctx)).toBe("https://docs.example/troubleshooting-and-support/new-page"); + expect(resolveLink("../nowhere/page.md", ctx)).toBeNull(); + }); + + it("rewrites every link, unwraps unresolvable ones, returns the first URL", () => { + const { text, link } = rewriteLinks("See [A](../troubleshooting/faq.md) and [B](http://x) and [C](https://c).", ctx); + expect(text).toBe("See [A](https://docs.example/troubleshooting-and-support/faq) and B and [C](https://c)."); + expect(link).toBe("https://docs.example/troubleshooting-and-support/faq"); + }); +}); + +describe("parseBlamePorcelain", () => { + it("maps line numbers to author-time and uncommitted lines to null", () => { + const map = parseBlamePorcelain(blameFor({ 14: 1757000000, 15: null, 17: 1757100000 })); + expect(map.get(14)).toBe(1757000000); + expect(map.get(15)).toBeNull(); + expect(map.get(17)).toBe(1757100000); + expect(map.get(16)).toBeUndefined(); + }); +}); + +describe("buildPayload", () => { + const now = new Date("2026-09-08T10:00:00.000Z"); + const payload = buildPayload({ + docsUrl: "https://docs.example/", + docsCommit: "abc1234", + summary: SUMMARY, + now, + channels: { + platform: { markdown: PLATFORM_MD, blame: blameFor({ 14: 1757200000, 15: null, 17: 1757100000, 22: 1756000000, 26: 1755000000, 30: 1754000000 }) }, + agent: { markdown: AGENT_MD, blame: blameFor({ 7: 1757300000, 8: 1757250000 }) }, + }, + }); + + it("carries schema, commit and channel docs URLs", () => { + expect(payload.schemaVersion).toBe(1); + expect(payload.docsCommit).toBe("abc1234"); + expect(payload.generatedUtc).toBe(now.toISOString()); + expect(payload.channels.platform.docsUrl).toBe("https://docs.example/changelog/platform-changelog"); + expect(payload.channels.agent.docsUrl).toBe("https://docs.example/changelog/agent-changelog"); + }); + + it("keeps the three newest periods only and preserves file order", () => { + const periods = payload.channels.platform.entries.map(e => e.period); + expect(periods).toEqual(["September 2026", "September 2026", "September 2026", "August 2026", "July 2026"]); + }); + + it("dates bullets from blame and uncommitted ones from now", () => { + const [first, second] = payload.channels.platform.entries; + expect(first.addedUtc).toBe(new Date(1757200000 * 1000).toISOString()); + expect(second.addedUtc).toBe(now.toISOString()); + }); + + it("splits platform titles, rewrites links and picks the first as the read-more target", () => { + const first = payload.channels.platform.entries[0]; + expect(first.title).toBe("Filter widgets by priority"); + expect(first.body).toBe( + "Every chip filters the list. See [Dashboard](https://docs.example/portal-guide/dashboard-and-sessions#search-syntax) and [Concepts](https://docs.example/rules/analyze-rules/concepts).", + ); + expect(first.link).toBe("https://docs.example/portal-guide/dashboard-and-sessions#search-syntax"); + }); + + it("leaves agent bullets untitled with the sentence as body", () => { + const [a, b] = payload.channels.agent.entries; + expect(a.title).toBeNull(); + expect(a.body).toBe("Diagnostics upload events now report how many files the package skipped"); + expect(a.link).toBeNull(); + expect(b.link).toBe("https://docs.example/changelog/platform-changelog"); + }); + + it("gives every entry a stable id that differs between entries", () => { + const ids = payload.channels.platform.entries.map(e => e.id); + expect(new Set(ids).size).toBe(ids.length); + expect(stableId("x")).toBe(stableId("x")); + expect(stableId("x")).not.toBe(stableId("y")); + }); +}); + +describe("emptyPayload", () => { + it("has the full shape with no entries", () => { + const p = emptyPayload("https://docs.example"); + expect(p.channels.platform.entries).toEqual([]); + expect(p.channels.agent.entries).toEqual([]); + expect(p.channels.agent.docsUrl).toBe("https://docs.example/changelog/agent-changelog"); + expect(p.docsCommit).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/scripts/whats-new/build-whats-new.js b/src/Web/autopilot-monitor-web/scripts/whats-new/build-whats-new.js new file mode 100644 index 00000000..23b2f218 --- /dev/null +++ b/src/Web/autopilot-monitor-web/scripts/whats-new/build-whats-new.js @@ -0,0 +1,105 @@ +/** + * Generates public/whats-new.json — the "What's new" panel's data, served verbatim at + * /whats-new.json by the static export. Runs in prebuild, like generate-version.js. + * + * Source of truth is the customer docs repo (autopilotmonitor-docs): the two changelog + * markdown files plus SUMMARY.md for the GitBook URL shape. `git blame` on the checkout + * dates every bullet, which is what makes "new since you last looked" possible without + * any dates in the authoring format. + * + * Docs checkout location, in order: + * 1. WHATS_NEW_DOCS_DIR env (deploy-web.yml checks the docs repo out and sets this) + * 2. --docs (local runs) + * Without either, a LOCAL build writes an empty payload (valid shape, no entries) so the + * panel degrades to its docs links; a CI build (CI=true) fails — production must never + * ship an empty or stale What's new. + * + * The output is gitignored: addedUtc/generatedUtc are derived from the docs checkout. + */ + +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const core = require("./whats-new-core.js"); + +const WEB_ROOT = path.resolve(__dirname, "..", ".."); +const OUTPUT_FILE = path.join(WEB_ROOT, "public", "whats-new.json"); +const DOCS_URL = "https://docs.autopilotmonitor.com"; + +function parseArgs(argv) { + const args = { docs: process.env.WHATS_NEW_DOCS_DIR || null }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--docs" && argv[i + 1]) args.docs = argv[++i]; + } + return args; +} + +function git(docsDir, gitArgs) { + return execFileSync("git", ["-C", docsDir, ...gitArgs], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: 32 * 1024 * 1024, + }); +} + +function readChannel(docsDir, relFile) { + const markdown = fs.readFileSync(path.join(docsDir, relFile), "utf-8"); + let blame = ""; + try { + blame = git(docsDir, ["blame", "--line-porcelain", "--", relFile]); + } catch (err) { + // A shallow or missing history yields no dates: every bullet then counts as + // published now, which would light the badge for everyone on each deploy. + throw new Error(`git blame failed for ${relFile} in ${docsDir} — is the checkout complete (fetch-depth: 0)? ${err.message}`); + } + return { markdown, blame }; +} + +function writePayload(payload, note) { + fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf-8"); + const counts = Object.entries(payload.channels) + .map(([name, ch]) => `${name}=${ch.entries.length}`) + .join(", "); + console.log(`Generated ${OUTPUT_FILE} (${note}; ${counts})`); +} + +function main() { + const { docs } = parseArgs(process.argv.slice(2)); + + if (!docs) { + if (process.env.CI) { + console.error("whats-new: WHATS_NEW_DOCS_DIR is not set in CI — the docs checkout step is missing. Refusing to ship an empty What's new."); + process.exit(1); + } + writePayload(core.emptyPayload(DOCS_URL), "no docs checkout — empty payload"); + return; + } + + const docsDir = path.resolve(docs); + if (!fs.existsSync(path.join(docsDir, "SUMMARY.md"))) { + console.error(`whats-new: ${docsDir} has no SUMMARY.md — not a docs checkout.`); + process.exit(1); + } + + let docsCommit = null; + try { + docsCommit = git(docsDir, ["rev-parse", "--short=7", "HEAD"]).trim() || null; + } catch { + // no history → null; blame below will report the real problem if there is one + } + + const payload = core.buildPayload({ + docsUrl: DOCS_URL, + docsCommit, + summary: fs.readFileSync(path.join(docsDir, "SUMMARY.md"), "utf-8"), + channels: { + platform: readChannel(docsDir, core.CHANNEL_FILES.platform), + agent: readChannel(docsDir, core.CHANNEL_FILES.agent), + }, + }); + + writePayload(payload, `docs @ ${docsCommit ?? "unknown"}`); +} + +main(); diff --git a/src/Web/autopilot-monitor-web/scripts/whats-new/whats-new-core.js b/src/Web/autopilot-monitor-web/scripts/whats-new/whats-new-core.js new file mode 100644 index 00000000..3055c720 --- /dev/null +++ b/src/Web/autopilot-monitor-web/scripts/whats-new/whats-new-core.js @@ -0,0 +1,375 @@ +/** + * Pure functions behind scripts/whats-new/build-whats-new.js — no I/O, so the + * markdown → payload transformation is unit-tested in isolation. + * + * The two customer changelogs in the docs repo (changelog/platform-changelog.md, + * changelog/agent-changelog.md) are the ONLY authoritative source of "What's new". + * This module reads exactly the authoring format the `/changelog` skill produces — + * `## ` blocks with `* ` bullets — and turns it into the JSON the portal + * panel renders. Nothing here adds meaning the markdown does not carry; the only + * derived facts are the per-bullet publish date (from `git blame`, supplied by the + * caller) and the absolute docs URL of every relative link. + */ + +const SCHEMA_VERSION = 1; + +/** Newest blocks per channel that make it into the payload. */ +const MAX_PERIODS = 3; +/** Hard cap per channel so a busy quarter cannot balloon the file. */ +const MAX_ENTRIES = 60; + +const CHANNEL_FILES = { + platform: "changelog/platform-changelog.md", + agent: "changelog/agent-changelog.md", +}; + +// ── Markdown ───────────────────────────────────────────────── + +/** CRLF/CR → LF. The docs repo is authored on Windows; `.` never matches `\r`. */ +function normalizeNewlines(text) { + return text.replace(/\r\n?/g, "\n"); +} + +/** + * @typedef {{ text: string, line: number }} Bullet + * @typedef {{ period: string, bullets: Bullet[] }} Block + * @typedef {{ id: string, addedUtc: string, period: string, title: string | null, body: string, link: string | null }} Entry + * @typedef {{ docsUrl: string, entries: Entry[] }} ChannelData + */ + +/** + * Parses one changelog file into `{ period, bullets: [{ text, line }] }[]` in file + * order (newest first, as the authoring convention keeps it). `line` is the 1-based + * line number of the bullet's first line in the ORIGINAL file — the key `git blame` + * dates are looked up by. Continuation lines (indented, not a new bullet) are folded + * into the bullet with a single space. + * + * Everything before the first `## ` heading (frontmatter, intro, badges) is skipped. + * @returns {Block[]} + */ +function parseChangelog(markdown) { + const lines = normalizeNewlines(markdown).split("\n"); + /** @type {Block[]} */ + const blocks = []; + /** @type {Block | null} */ + let current = null; + /** @type {Bullet | null} */ + let bullet = null; + + const flushBullet = () => { + if (bullet && current) current.bullets.push(bullet); + bullet = null; + }; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const heading = /^##\s+(.+?)\s*$/.exec(raw); + if (heading) { + flushBullet(); + current = { period: heading[1], bullets: [] }; + blocks.push(current); + continue; + } + if (!current) continue; + + const item = /^\*\s+(.*)$/.exec(raw); + if (item) { + flushBullet(); + bullet = { text: item[1].trim(), line: i + 1 }; + continue; + } + if (bullet && /^\s+\S/.test(raw)) { + bullet.text += " " + raw.trim(); + continue; + } + flushBullet(); + } + flushBullet(); + return blocks; +} + +/** + * Platform bullets lead with a bold title and an em dash: `**Title** — Body`. + * Agent bullets are a single sentence. Returns `{ title, body }`, title null when + * the bullet has no bold lead. + */ +function splitTitle(text) { + const m = /^\*\*(.+?)\*\*\s*(?:—|–|-)\s*(.*)$/s.exec(text); + if (m) return { title: m[1].trim(), body: m[2].trim() }; + return { title: null, body: text.trim() }; +} + +// ── GitBook URL map ────────────────────────────────────────── + +/** + * GitBook publishes a page under `//`, where the + * group slug is the slugified `## ` heading of SUMMARY.md (not the folder name: + * `troubleshooting/` → `troubleshooting-and-support`), a page slug is its file name + * without `.md`, and a `README.md` takes its folder's name. `docsPaths.ts` in the + * portal pins the same shape. + */ +function slugifyHeading(heading) { + return heading + .toLowerCase() + .replace(/&/g, " and ") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function pageSlug(filePath) { + const parts = filePath.split("/"); + const file = parts[parts.length - 1]; + if (/^readme\.md$/i.test(file)) return parts.length >= 2 ? parts[parts.length - 2] : ""; + return file.replace(/\.md$/i, ""); +} + +/** + * SUMMARY.md → Map. Root pages (before the first + * group) map to `/`, `README.md` to `/`. Nesting follows the list indentation: + * a deeper bullet is published under its parent's URL. + */ +function buildSummaryUrlMap(summaryMarkdown) { + const map = new Map(); + let groupSlug = null; + // Slugs of the ancestors at each indentation depth for the current group. + const stack = []; + + for (const raw of normalizeNewlines(summaryMarkdown).split("\n")) { + const heading = /^##\s+(.+?)\s*$/.exec(raw); + if (heading) { + groupSlug = slugifyHeading(heading[1]); + stack.length = 0; + continue; + } + const item = /^(\s*)\*\s+\[[^\]]*\]\(([^)]+)\)/.exec(raw); + if (!item) continue; + const depth = Math.floor(item[1].replace(/\t/g, " ").length / 2); + const file = item[2].trim(); + const slug = pageSlug(file); + stack.length = depth; + stack[depth] = slug; + + let url; + if (groupSlug === null) { + url = /^readme\.md$/i.test(file) ? "/" : `/${slug}`; + } else { + url = "/" + [groupSlug, ...stack.slice(0, depth + 1)].join("/"); + } + map.set(file, url); + } + return map; +} + +/** Folder-name → group-slug fallback for pages SUMMARY.md does not list. */ +function buildFolderSlugMap(urlMap) { + const folders = new Map(); + for (const [file, url] of urlMap) { + const slash = file.indexOf("/"); + if (slash < 0) continue; + const folder = file.slice(0, slash); + const group = url.split("/")[1]; + if (folder && group && !folders.has(folder)) folders.set(folder, group); + } + return folders; +} + +/** Resolves `../a/b.md` against `changelog/` without touching the filesystem. */ +function resolveRelative(fromDir, href) { + const parts = (fromDir ? fromDir.split("/") : []).filter(Boolean); + for (const seg of href.split("/")) { + if (seg === "..") parts.pop(); + else if (seg && seg !== ".") parts.push(seg); + } + return parts.join("/"); +} + +/** + * Turns one markdown link target from a changelog bullet into an absolute URL, or + * null when the target is neither an https URL nor a known docs page. `#anchor` + * is carried over verbatim (GitBook heading slugs match the markdown anchors). + */ +function resolveLink(href, ctx) { + const trimmed = href.trim(); + if (/^https:\/\//i.test(trimmed)) return trimmed; + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null; // http:, mailto:, etc. + + const hashIndex = trimmed.indexOf("#"); + const pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed; + const anchor = hashIndex >= 0 ? trimmed.slice(hashIndex) : ""; + if (!pathPart) return null; // same-page anchor — meaningless outside the docs page + + const file = resolveRelative(ctx.fromDir, pathPart); + let urlPath = ctx.urlMap.get(file); + if (!urlPath) { + const slash = file.indexOf("/"); + if (slash < 0) return null; + const group = ctx.folderSlugMap.get(file.slice(0, slash)); + if (!group) return null; + const rest = file + .slice(slash + 1) + .replace(/\/?readme\.md$/i, "") + .replace(/\.md$/i, ""); + urlPath = "/" + [group, rest].filter(Boolean).join("/"); + } + return `${ctx.docsUrl}${urlPath}${anchor}`; +} + +/** + * Rewrites every `[label](target)` in the bullet to an absolute URL; links that + * cannot be resolved lose their brackets and keep the label. Returns the rewritten + * text and the first resolved URL (the panel's "Read update" target). + */ +function rewriteLinks(text, ctx) { + let first = null; + const rewritten = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, target) => { + const url = resolveLink(target, ctx); + if (!url) return label; + if (!first) first = url; + return `[${label}](${url})`; + }); + return { text: rewritten, link: first }; +} + +// ── Blame ──────────────────────────────────────────────────── + +/** + * `git blame --line-porcelain` → Map<1-based line, epoch seconds (author-time)>. + * Uncommitted lines carry the all-zero hash and no usable time; they map to null. + */ +function parseBlamePorcelain(output) { + const map = new Map(); + let line = null; + let time = null; + let uncommitted = false; + for (const raw of normalizeNewlines(output).split("\n")) { + const header = /^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/.exec(raw); + if (header) { + line = Number(header[2]); + time = null; + uncommitted = /^0+$/.test(header[1]); + continue; + } + if (line === null) continue; + const at = /^author-time (\d+)$/.exec(raw); + if (at) { + time = Number(at[1]); + continue; + } + if (raw.startsWith("\t")) { + map.set(line, uncommitted ? null : time); + line = null; + } + } + return map; +} + +// ── Payload ────────────────────────────────────────────────── + +/** FNV-1a 32-bit over the normalized text — a stable React key across regenerations. */ +function stableId(text) { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h.toString(16).padStart(8, "0"); +} + +function toIso(epochSeconds, fallbackIso) { + if (epochSeconds === null || epochSeconds === undefined) return fallbackIso; + return new Date(epochSeconds * 1000).toISOString(); +} + +/** + * Builds one channel's entry list from markdown + blame dates. + * `nowIso` dates bullets git has not committed yet (local runs on a dirty tree). + * @returns {Entry[]} + */ +function buildChannelEntries(markdown, blameByLine, ctx, nowIso) { + const blocks = parseChangelog(markdown).slice(0, MAX_PERIODS); + /** @type {Entry[]} */ + const entries = []; + for (const block of blocks) { + for (const bullet of block.bullets) { + if (entries.length >= MAX_ENTRIES) return entries; + const { title, body } = splitTitle(bullet.text); + const { text, link } = rewriteLinks(body, ctx); + entries.push({ + id: stableId(`${block.period}|${bullet.text}`), + addedUtc: toIso(blameByLine.get(bullet.line), nowIso), + period: block.period, + title, + body: text, + link, + }); + } + } + return entries; +} + +/** + * @param input.docsUrl e.g. "https://docs.autopilotmonitor.com" + * @param input.docsCommit short SHA of the docs checkout, or null + * @param input.summary SUMMARY.md text + * @param input.channels { platform: { markdown, blame }, agent: { markdown, blame } } + * where `blame` is the raw `git blame --line-porcelain` output + * @param input.now Date used for generatedUtc and uncommitted bullets + */ +function buildPayload(input) { + const now = input.now ?? new Date(); + const nowIso = now.toISOString(); + const urlMap = buildSummaryUrlMap(input.summary); + const ctx = { + docsUrl: input.docsUrl.replace(/\/+$/, ""), + fromDir: "changelog", + urlMap, + folderSlugMap: buildFolderSlugMap(urlMap), + }; + + /** @param {"platform" | "agent"} channel @returns {ChannelData} */ + const buildChannel = channel => { + const source = input.channels[channel]; + const file = CHANNEL_FILES[channel]; + return { + docsUrl: `${ctx.docsUrl}${urlMap.get(file) ?? "/" + file.replace(/\.md$/i, "")}`, + entries: buildChannelEntries(source.markdown, parseBlamePorcelain(source.blame), ctx, nowIso), + }; + }; + + return { + schemaVersion: SCHEMA_VERSION, + generatedUtc: nowIso, + docsCommit: input.docsCommit ?? null, + channels: { platform: buildChannel("platform"), agent: buildChannel("agent") }, + }; +} + +/** The payload a build without a docs checkout ships: valid shape, no entries. */ +function emptyPayload(docsUrl, now = new Date()) { + const base = docsUrl.replace(/\/+$/, ""); + return { + schemaVersion: SCHEMA_VERSION, + generatedUtc: now.toISOString(), + docsCommit: null, + channels: { + platform: { docsUrl: `${base}/changelog/platform-changelog`, entries: [] }, + agent: { docsUrl: `${base}/changelog/agent-changelog`, entries: [] }, + }, + }; +} + +module.exports = { + SCHEMA_VERSION, + CHANNEL_FILES, + normalizeNewlines, + parseChangelog, + splitTitle, + slugifyHeading, + buildSummaryUrlMap, + resolveLink, + rewriteLinks, + parseBlamePorcelain, + stableId, + buildPayload, + emptyPayload, +}; diff --git a/src/Web/autopilot-monitor-web/staticwebapp.config.json b/src/Web/autopilot-monitor-web/staticwebapp.config.json index da44aa02..a8a1cf89 100644 --- a/src/Web/autopilot-monitor-web/staticwebapp.config.json +++ b/src/Web/autopilot-monitor-web/staticwebapp.config.json @@ -23,6 +23,7 @@ { "route": "/roadmap", "redirect": "/", "statusCode": 302 }, { "route": "/version.json", "headers": { "Cache-Control": "no-store" } }, + { "route": "/whats-new.json", "headers": { "Cache-Control": "public, max-age=300" } }, { "route": "/sessions/index.txt", "rewrite": "/sessions/index.txt" }, { "route": "/sessions/network-timeline/index.txt", "rewrite": "/sessions/network-timeline/index.txt" }, diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index d3796351..6f1a38ce 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -4210,6 +4210,24 @@ "kind": "primitive", "name": "boolean" } + }, + { + "name": "whatsNewSeenPlatformUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit)." + }, + { + "name": "whatsNewSeenAgentUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Same for the Agent tab; null = never and the key is omitted." } ] }, @@ -23885,6 +23903,28 @@ } } ] + }, + "WhatsNewSeenRequest": { + "kind": "object", + "doc": "Body of PUT auth/me/whats-new-seen.", + "fields": [ + { + "name": "channel", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "What's-new channel: \"platform\" or \"agent\"." + }, + { + "name": "seenUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "UTC timestamp when the caller viewed the channel." + } + ] } } } diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index a64e48b3..a12ac82c 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -758,6 +758,10 @@ export interface AuthMeResponse { homedApp: string; bootstrapTokenEnabled: boolean; unrestrictedModeEnabled: boolean; + /** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */ + whatsNewSeenPlatformUtc?: string; + /** Same for the Agent tab; null = never and the key is omitted. */ + whatsNewSeenAgentUtc?: string; } export interface AutoResolveCpeMappingItem { @@ -5248,3 +5252,11 @@ export interface VerdictCalibrationTrendWindow { sessions: number; sharePct: number; } + +/** Body of PUT auth/me/whats-new-seen. */ +export interface WhatsNewSeenRequest { + /** What's-new channel: "platform" or "agent". */ + channel: string; + /** UTC timestamp when the caller viewed the channel. */ + seenUtc: string; +}