mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-22 19:55:35 +00:00
Agent Host changes for agents/improve-changelog-promotion-ui (#152)
This commit is contained in:
committed by
GitHub
parent
4df52b3da9
commit
4e73ba8900
@@ -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('?')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TableEntity>(
|
||||
TenantId,
|
||||
TableStorageService.PresenceRowKey(Upn),
|
||||
It.Is<IEnumerable<string>>(s => s.SequenceEqual(new[] { "WhatsNewSeenPlatformUtc", "WhatsNewSeenAgentUtc" })),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<TableEntity>(
|
||||
TenantId,
|
||||
TableStorageService.PresenceRowKey(Upn),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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<TableClient> 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<TableEntity>(),
|
||||
It.IsAny<TableUpdateMode>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, mode, _) =>
|
||||
{
|
||||
Upserts.Add((e, mode));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(Constants.TableNames.UserPresence)).Returns(Table.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
public void SetupRead(TableEntity entity)
|
||||
{
|
||||
Table.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
entity.PartitionKey,
|
||||
entity.RowKey,
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(entity, Mock.Of<Response>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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<UserWhatsNewSeen> 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<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window)
|
||||
=> _storage.GetActivePresenceAsync(window);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PUT /api/auth/me/whats-new-seen
|
||||
/// Marks the caller's What's-new channel as seen.
|
||||
/// </summary>
|
||||
[Function("MarkWhatsNewSeen")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> 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<WhatsNewSeenRequest>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<string>? 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)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com)
|
||||
/// </summary>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1091,7 +1091,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence);
|
||||
var entity = (await tableClient.GetEntityAsync<TableEntity>(
|
||||
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<TableEntity>(
|
||||
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)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Live Presence (one row per user, upserted) ---
|
||||
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
|
||||
Task RecordUserPresenceAsync(string tenantId, string upn, string userRole);
|
||||
/// <summary>Gets the caller's per-channel What's-new seen marks from the presence row; nulls mean never or unavailable.</summary>
|
||||
Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn);
|
||||
/// <summary>Monotonically merge-upserts the caller's What's-new seen mark for one channel without touching presence fields.</summary>
|
||||
Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc);
|
||||
/// <summary>Returns all users whose LastSeen falls within the given window, newest first (cross-tenant).</summary>
|
||||
Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window);
|
||||
/// <summary>Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff (drops one-off testers). Returns the number deleted.</summary>
|
||||
|
||||
@@ -37,6 +37,20 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
public bool BootstrapTokenEnabled { get; set; }
|
||||
public bool UnrestrictedModeEnabled { get; set; }
|
||||
|
||||
/// <summary>When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit).</summary>
|
||||
public DateTime? WhatsNewSeenPlatformUtc { get; set; }
|
||||
/// <summary>Same for the Agent tab; null = never and the key is omitted.</summary>
|
||||
public DateTime? WhatsNewSeenAgentUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body of PUT auth/me/whats-new-seen.</summary>
|
||||
public class WhatsNewSeenRequest : IApiRequest
|
||||
{
|
||||
/// <summary>What's-new channel: "platform" or "agent".</summary>
|
||||
public string Channel { get; set; } = default!;
|
||||
/// <summary>UTC timestamp when the caller viewed the channel.</summary>
|
||||
public DateTime SeenUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>The caller's per-channel What's-new seen marks from the UserPresence row.</summary>
|
||||
public sealed class UserWhatsNewSeen
|
||||
{
|
||||
public UserWhatsNewSeen(DateTime? platformUtc = null, DateTime? agentUtc = null)
|
||||
{
|
||||
PlatformUtc = platformUtc;
|
||||
AgentUtc = agentUtc;
|
||||
}
|
||||
|
||||
public DateTime? PlatformUtc { get; }
|
||||
public DateTime? AgentUtc { get; }
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
<span className="font-semibold">Actively developed.</span>{" "}
|
||||
Autopilot Monitor recognizes a wide range of deployment scenarios and improves
|
||||
continuously — your reports directly shape it.{" "}
|
||||
If something looks off, check the{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/changelog/platform-changelog`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
If something looks off, check{" "}
|
||||
<button
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
onClick={() => trackLink("platform_changelog")}
|
||||
onClick={() => { trackLink("whats_new"); whatsNew.open("banner", "platform"); }}
|
||||
>
|
||||
Platform Changelog
|
||||
</a>{" "}
|
||||
What's new
|
||||
</button>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/troubleshooting/service-announcements`}
|
||||
|
||||
@@ -896,6 +896,26 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
animation: lpFadeUp 0.4s ease-out both;
|
||||
}
|
||||
|
||||
/* What's new flyout: slides in from the right edge; unmounted on close, so no exit animation. */
|
||||
@keyframes whatsNewSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.whats-new-card {
|
||||
animation: whatsNewSlideIn 0.22s ease-out both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.whats-new-card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lpBlink {
|
||||
0%,
|
||||
49% {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { WhatsNewTile } from "../../components/landing/WhatsNewTile";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
@@ -6,6 +7,9 @@ const GITHUB_ISSUES = "https://github.com/okieselbach/AutopilotMonitor/issues";
|
||||
const LINKEDIN_PROFILE = "https://www.linkedin.com/in/oliver-kieselbach/";
|
||||
const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new";
|
||||
|
||||
const TILE_CLASS =
|
||||
"rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-5 py-4 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors";
|
||||
|
||||
const SELF_SERVICE_LINKS = [
|
||||
{
|
||||
label: "Documentation",
|
||||
@@ -22,11 +26,6 @@ const SELF_SERVICE_LINKS = [
|
||||
description: "Known issues and current service status.",
|
||||
href: `${DOCS_URL}/troubleshooting/service-announcements`,
|
||||
},
|
||||
{
|
||||
label: "Platform Changelog",
|
||||
description: "What changed recently in the portal and backend.",
|
||||
href: `${DOCS_URL}/changelog/platform-changelog`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function HelpPage() {
|
||||
@@ -142,12 +141,13 @@ export default function HelpPage() {
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-5 py-4 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors"
|
||||
className={TILE_CLASS}
|
||||
>
|
||||
<span className="block text-sm font-semibold text-gray-800 mb-1">{link.label} →</span>
|
||||
<span className="block text-sm text-gray-600 leading-relaxed">{link.description}</span>
|
||||
</a>
|
||||
))}
|
||||
<WhatsNewTile className={TILE_CLASS} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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({
|
||||
</GlobalSidebar>
|
||||
<ScrollToTopButton />
|
||||
<FeedbackBubble />
|
||||
<WhatsNewPanelHost />
|
||||
</SidebarProvider>
|
||||
</TenantProvider>
|
||||
</TenantNotificationProvider>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -485,10 +488,11 @@ export default function Navbar() {
|
||||
|
||||
{/* Help Menu (?) — hidden on <sm, moved to overflow */}
|
||||
<div className="hidden sm:block relative" ref={helpRef}>
|
||||
<button onClick={() => setShowHelp(!showHelp)} className="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Help & Info">
|
||||
<button onClick={() => setShowHelp(!showHelp)} className="relative p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Help & Info">
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<WhatsNewIconBadge count={whatsNew.totalUnseen} />
|
||||
</button>
|
||||
|
||||
{showHelp && (
|
||||
@@ -506,18 +510,17 @@ export default function Navbar() {
|
||||
<span>Documentation</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`${DOCS_URL}${DOCS_PATHS.platformChangelog}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowHelp(false)}
|
||||
onClick={() => { setShowHelp(false); whatsNew.open("help"); }}
|
||||
>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<span>Changelog</span>
|
||||
</a>
|
||||
<span>What's new</span>
|
||||
<WhatsNewCountBadge count={whatsNew.totalUnseen} className="ml-auto" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${DOCS_URL}${DOCS_PATHS.serviceAnnouncements}`}
|
||||
@@ -578,12 +581,13 @@ export default function Navbar() {
|
||||
<div className="sm:hidden relative" ref={overflowRef}>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(!showOverflow); setOverflowSubmenu(null); }}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
className="relative p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
title="More"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
<WhatsNewIconBadge count={whatsNew.totalUnseen} />
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50 py-1">
|
||||
@@ -641,6 +645,7 @@ export default function Navbar() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Help</span>
|
||||
<WhatsNewCountBadge count={whatsNew.totalUnseen} />
|
||||
</div>
|
||||
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
@@ -722,10 +727,11 @@ export default function Navbar() {
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
|
||||
<span>Documentation</span>
|
||||
</a>
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.platformChangelog}`} target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<button type="button" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); whatsNew.open("overflow"); }}>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
|
||||
<span>Changelog</span>
|
||||
</a>
|
||||
<span>What's new</span>
|
||||
<WhatsNewCountBadge count={whatsNew.totalUnseen} className="ml-auto" />
|
||||
</button>
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.serviceAnnouncements}`} target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
|
||||
<span>Service Announcements</span>
|
||||
|
||||
@@ -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<WhatsNewChannel, string> = { 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 (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center min-w-4 h-4 px-1 text-[10px] font-bold leading-none text-white bg-red-600 rounded-full ${className}`}
|
||||
aria-label={`${count} new`}
|
||||
>
|
||||
{formatBadgeCount(count)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The badge pinned to the top-right corner of an icon button. */
|
||||
export function WhatsNewIconBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return <WhatsNewCountBadge count={count} className="absolute top-0.5 right-0.5" />;
|
||||
}
|
||||
|
||||
function BellIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="py-4 border-b border-[var(--lp-line-soft)] last:border-b-0">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--lp-ink-faint)]">
|
||||
<time dateTime={entry.addedUtc}>{dateFormatter.format(new Date(entry.addedUtc))}</time>
|
||||
{isNew && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--lp-accent-soft)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--lp-accent-ink)]">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.title ? (
|
||||
<>
|
||||
<h3 className="mt-1 text-[15px] font-semibold leading-snug text-[var(--lp-ink)]">{formatInlineMarkdown(entry.title, { emphasis: true })}</h3>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-[var(--lp-ink-soft)]">
|
||||
{formatInlineMarkdown(entry.body, MARKDOWN_OPTIONS)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-[15px] leading-relaxed text-[var(--lp-ink)]">
|
||||
{formatInlineMarkdown(entry.body, MARKDOWN_OPTIONS)}
|
||||
</p>
|
||||
)}
|
||||
{entry.link && (
|
||||
<a
|
||||
href={entry.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onLink}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm font-semibold text-[var(--lp-accent-ink)] hover:underline"
|
||||
>
|
||||
Read update
|
||||
<ExternalIcon className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRows() {
|
||||
return (
|
||||
<ul className="animate-pulse" aria-hidden="true">
|
||||
{[0, 1, 2].map(i => (
|
||||
<li key={i} className="py-4 border-b border-[var(--lp-line-soft)] last:border-b-0">
|
||||
<div className="h-3 w-20 rounded bg-[var(--lp-surface-2)]" />
|
||||
<div className="mt-2 h-4 w-3/4 rounded bg-[var(--lp-surface-2)]" />
|
||||
<div className="mt-2 h-3 w-full rounded bg-[var(--lp-surface-2)]" />
|
||||
<div className="mt-1 h-3 w-5/6 rounded bg-[var(--lp-surface-2)]" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLButtonElement>(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<WhatsNewSeen>(() => 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 (
|
||||
<ModalPortal>
|
||||
<div className={`fixed inset-0 z-[60] ${rootClassName ?? ""}`} role="presentation">
|
||||
<div className="absolute inset-0 bg-black/20 backdrop-blur-[2px]" onClick={wn.close} aria-hidden="true" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="whats-new-title"
|
||||
className="whats-new-card absolute top-2 right-2 bottom-2 sm:top-4 sm:right-4 sm:bottom-4 w-[calc(100vw-1rem)] sm:w-[28rem] flex flex-col rounded-2xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] shadow-2xl overflow-hidden"
|
||||
>
|
||||
<header className="px-5 pt-5 pb-4 border-b border-[var(--lp-line-soft)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
|
||||
<BellIcon className="w-5 h-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="whats-new-title" className="text-lg font-bold leading-tight text-[var(--lp-ink)]">What's new</h2>
|
||||
<p className="mt-0.5 text-sm text-[var(--lp-ink-soft)]">News and improvements from Autopilot Monitor.</p>
|
||||
</div>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={wn.close}
|
||||
aria-label="Close"
|
||||
className="-mr-2 -mt-1 p-2 rounded-lg text-[var(--lp-ink-faint)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex rounded-lg bg-[var(--lp-surface-2)] p-1" role="tablist" aria-label="Channel">
|
||||
{WHATS_NEW_CHANNELS.map(c => {
|
||||
const active = c === channel;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => switchChannel(c)}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-[var(--lp-surface)] text-[var(--lp-ink)] shadow-sm"
|
||||
: "text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)]"
|
||||
}`}
|
||||
>
|
||||
{CHANNEL_LABEL[c]}
|
||||
<WhatsNewCountBadge count={wn.unseen[c]} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5" role="tabpanel">
|
||||
{wn.status === "loading" || wn.status === "idle" ? (
|
||||
<SkeletonRows />
|
||||
) : wn.status === "error" || entries.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-[var(--lp-ink-soft)]">
|
||||
{wn.status === "error" ? "The update list could not be loaded right now." : "No recent entries."}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--lp-ink-soft)]">The full changelog is always available in the documentation.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{entries.map((entry, i) => {
|
||||
const showPeriod = i === 0 || entries[i - 1].period !== entry.period;
|
||||
return (
|
||||
<li key={entry.id} className="list-none">
|
||||
{showPeriod && (
|
||||
<p className={`text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--lp-ink-faint)] ${i === 0 ? "pt-4" : "pt-6"}`}>
|
||||
{entry.period}
|
||||
</p>
|
||||
)}
|
||||
<ul>
|
||||
<EntryRow entry={entry} isNew={newIds.has(entry.id)} />
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-5 py-4 border-t border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent("whats_new_link_clicked", { entryId: `all-${channel}` })}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[var(--lp-accent-ink)] px-4 py-2.5 text-sm font-semibold text-white hover:brightness-110 transition-all"
|
||||
>
|
||||
View all {CHANNEL_LABEL[channel].toLowerCase()} updates
|
||||
<ExternalIcon className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -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 <body>, 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 <WhatsNewPanel rootClassName={onPublicPage ? "landing-v2" : undefined} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
<path d="M2.5 8a10 10 0 0 1 2-4.5" />
|
||||
<path d="M21.5 8a10 10 0 0 0-2-4.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
>
|
||||
<GitHubIcon className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => whatsNew.open("landing")}
|
||||
className="hidden md:block p-2 rounded-lg text-[var(--lp-ink-faint)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
|
||||
title="What's new"
|
||||
aria-label="What's new"
|
||||
>
|
||||
<BellIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
className="hidden md:block px-3 py-2 text-sm font-semibold text-[var(--lp-ink)] hover:text-[var(--lp-accent-ink)] transition-colors"
|
||||
@@ -169,6 +193,14 @@ export function LandingNavbar() {
|
||||
<GitHubIcon className="w-4 h-4" />
|
||||
GitHub
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMenuOpen(false); whatsNew.open("landing-menu"); }}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 text-[15px] font-medium rounded-lg text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
|
||||
>
|
||||
<BellIcon className="w-4 h-4" />
|
||||
What's new
|
||||
</button>
|
||||
<div className="h-px bg-[var(--lp-line-soft)] my-2" />
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useWhatsNew } from "@/hooks/useWhatsNew";
|
||||
|
||||
/**
|
||||
* The "What's new" tile on the Help page. Same look as the link tiles around it, but a
|
||||
* button: it opens the What's new panel instead of leaving for the docs. No counter —
|
||||
* the public site has no seen mark to count against.
|
||||
*/
|
||||
export function WhatsNewTile({ className }: { className: string }) {
|
||||
const whatsNew = useWhatsNew();
|
||||
return (
|
||||
<button type="button" onClick={() => whatsNew.open("help-page")} className={`${className} text-left w-full`}>
|
||||
<span className="block text-sm font-semibold text-gray-800 mb-1">What's new →</span>
|
||||
<span className="block text-sm text-gray-600 leading-relaxed">
|
||||
What changed recently in the portal and the agent.
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WhatsNewChannel, number>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<WhatsNewSeen>(
|
||||
() => ({
|
||||
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<WhatsNewSeenRequest>({ 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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
return React.isValidElement(node) ? (node.props as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <em>. Off by default for the same reason. */
|
||||
emphasis?: boolean;
|
||||
}
|
||||
|
||||
const BOLD_OR_CODE = "\\*\\*[^*]+\\*\\*|`[^`]+`";
|
||||
const LINK = "\\[[^\\]]+\\]\\([^)\\s]+\\)";
|
||||
const EMPHASIS = "(?<!\\*)\\*[^*\\s][^*]*\\*(?!\\*)";
|
||||
|
||||
function tokenPattern(options: InlineMarkdownOptions): RegExp {
|
||||
const alternatives = [BOLD_OR_CODE];
|
||||
if (options.links) alternatives.push(LINK);
|
||||
if (options.emphasis) alternatives.push(EMPHASIS);
|
||||
return new RegExp(`(${alternatives.join("|")})`, "g");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts basic inline markdown (**bold** and `code`) to React elements.
|
||||
* Returns an array of React nodes suitable for use as JSX children.
|
||||
* Converts basic inline markdown (**bold** and `code`, optionally [label](url) and
|
||||
* *emphasis*) to React elements. Returns an array of React nodes suitable for use as JSX children.
|
||||
*/
|
||||
export function formatInlineMarkdown(text: string): React.ReactNode[] {
|
||||
// Split on **bold** and `code` patterns, keeping the delimiters as capture groups
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
||||
export function formatInlineMarkdown(text: string, options: InlineMarkdownOptions = {}): React.ReactNode[] {
|
||||
// Split on the token patterns, keeping the delimiters as capture groups
|
||||
const parts = text.split(tokenPattern(options));
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith("**") && part.endsWith("**")) {
|
||||
@@ -26,6 +49,21 @@ export function formatInlineMarkdown(text: string): React.ReactNode[] {
|
||||
</code>
|
||||
);
|
||||
}
|
||||
if (options.links && part.startsWith("[")) {
|
||||
const m = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part);
|
||||
if (m) {
|
||||
const [, label, href] = m;
|
||||
if (!/^https?:\/\//i.test(href)) return label;
|
||||
return (
|
||||
<a key={i} href={href} target="_blank" rel="noopener noreferrer" className={options.linkClassName}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (options.emphasis && part.length > 2 && part.startsWith("*") && part.endsWith("*")) {
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 `## <Period>` 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<WhatsNewChannel, WhatsNewChannelData>;
|
||||
}
|
||||
|
||||
/** ISO timestamp per channel of the newest entry the user has seen; null = never. */
|
||||
export type WhatsNewSeen = Record<WhatsNewChannel, string | null>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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);
|
||||
}
|
||||
@@ -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<void> | null = null;
|
||||
let visibilityHooked = false;
|
||||
|
||||
function setState(patch: Partial<WhatsNewState>): 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<void> {
|
||||
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<unknown>(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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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:** ",
|
||||
"",
|
||||
"## 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<number, number | null>): 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();
|
||||
});
|
||||
});
|
||||
@@ -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 <path> (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();
|
||||
@@ -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 —
|
||||
* `## <Period>` 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 `<group-slug>/<parent-slugs…>/<page-slug>`, 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<repo-relative file path, url path>. Root pages (before the first
|
||||
* group) map to `/<slug>`, `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,
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user