diff --git a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs new file mode 100644 index 0000000000..fbcf95fd89 --- /dev/null +++ b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Models.Data; + +public record ProductTourProgress +{ + public ProductTourStatus Status { get; set; } + public DateTime UpdatedUtc { get; set; } + public int Version { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourStatus +{ + [JsonStringEnumMemberName("completed")] + [EnumMember(Value = "completed")] + Completed, + [JsonStringEnumMemberName("dismissed")] + [EnumMember(Value = "dismissed")] + Dismissed +} diff --git a/src/Exceptionless.Core/Models/Data/ProductTours.cs b/src/Exceptionless.Core/Models/Data/ProductTours.cs new file mode 100644 index 0000000000..2ce860ec3b --- /dev/null +++ b/src/Exceptionless.Core/Models/Data/ProductTours.cs @@ -0,0 +1,88 @@ +using System.Collections.Frozen; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Exceptionless.Core.Extensions; + +namespace Exceptionless.Core.Models.Data; + +public static class ProductTours +{ + public const string ConfigureProject = "configure-project"; + public const string CreateSavedView = "create-saved-view"; + public const string ExieAnnouncement = "exie-announcement"; + public const string InvestigateError = "investigate-error"; + public const string MeetExie = "meet-exie"; + public const string UiOverview = "ui-overview"; + public const string Welcome = "welcome"; + + public static IReadOnlyDictionary Versions { get; } = new Dictionary(StringComparer.Ordinal) + { + [ConfigureProject] = 1, + [CreateSavedView] = 1, + [ExieAnnouncement] = 1, + [InvestigateError] = 1, + [MeetExie] = 1, + [UiOverview] = 1, + [Welcome] = 1 + }.ToFrozenDictionary(StringComparer.Ordinal); + + public static IReadOnlyCollection TelemetryEvents { get; } = Enum.GetValues(); + public static IReadOnlyCollection LaunchSources { get; } = Enum.GetValues(); + + public static bool IsKnown(string name) => Versions.ContainsKey(name); + + public static bool IsValid(string name, int version) + { + return Versions.TryGetValue(name, out int currentVersion) && version > 0 && version <= currentVersion; + } + + public static string CreateTelemetrySource( + ProductTourTelemetryEvent telemetryEvent, + string tourName, + int version, + ProductTourLaunchSource launchSource) + { + return $"product-tour.{GetTelemetryName(telemetryEvent)}.{tourName}.v{version}.{GetLaunchSourceName(launchSource)}"; + } + + public static string GetTelemetryName(ProductTourTelemetryEvent telemetryEvent) => telemetryEvent.ToString().ToLowerUnderscoredWords('-'); + + public static string GetLaunchSourceName(ProductTourLaunchSource launchSource) => launchSource.ToString().ToLowerUnderscoredWords('-'); +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourTelemetryEvent +{ + [JsonStringEnumMemberName("completed")] + [EnumMember(Value = "completed")] + Completed, + [JsonStringEnumMemberName("dismissed")] + [EnumMember(Value = "dismissed")] + Dismissed, + [JsonStringEnumMemberName("shown")] + [EnumMember(Value = "shown")] + Shown, + [JsonStringEnumMemberName("started")] + [EnumMember(Value = "started")] + Started +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourLaunchSource +{ + [JsonStringEnumMemberName("automatic")] + [EnumMember(Value = "automatic")] + Automatic, + [JsonStringEnumMemberName("catalog")] + [EnumMember(Value = "catalog")] + Catalog, + [JsonStringEnumMemberName("command-palette")] + [EnumMember(Value = "command-palette")] + CommandPalette, + [JsonStringEnumMemberName("feature-announcement")] + [EnumMember(Value = "feature-announcement")] + FeatureAnnouncement, + [JsonStringEnumMemberName("help-menu")] + [EnumMember(Value = "help-menu")] + HelpMenu +} diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index cb6e154e50..cd8146ace8 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models.Data; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Models; @@ -24,6 +25,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public DateTime PasswordResetTokenExpiration { get; set; } public ICollection OAuthAccounts { get; init; } = new Collection(); public ICollection OrganizationPreferences { get; init; } = new Collection(); + public IDictionary ProductTours { get; init; } = new Dictionary(StringComparer.Ordinal); /// /// Gets or sets the users Full Name. diff --git a/src/Exceptionless.Core/Repositories/EventRepository.cs b/src/Exceptionless.Core/Repositories/EventRepository.cs index 3fba37a69d..264c90241c 100644 --- a/src/Exceptionless.Core/Repositories/EventRepository.cs +++ b/src/Exceptionless.Core/Repositories/EventRepository.cs @@ -1,10 +1,14 @@ -using Elastic.Clients.Elasticsearch.QueryDsl; +using Elastic.Clients.Elasticsearch.Aggregations; +using Elastic.Clients.Elasticsearch.QueryDsl; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Validation; using Exceptionless.DateTimeExtensions; using Foundatio.Repositories; +using Foundatio.Repositories.Elasticsearch.Extensions; +using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Repositories; @@ -82,6 +86,163 @@ public Task> GetByReferenceIdAsync(string projectId return FindAsync(q => q.Project(projectId).FieldEquals(e => e.ReferenceId, referenceId).SortDescending(e => e.Date), o => o.PageLimit(10)); } + public async Task GetProductTourUsageAsync(string projectId, DateTime utcStart, DateTime utcEnd, int recentLimit = 500) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + ArgumentOutOfRangeException.ThrowIfLessThan(recentLimit, 1); + if (utcEnd <= utcStart) + throw new ArgumentOutOfRangeException(nameof(utcEnd), "The end date must be later than the start date."); + + var sourcesByTour = ProductTours.Versions.ToDictionary( + pair => pair.Key, + pair => CreateProductTourSources(pair.Key, pair.Value), + StringComparer.Ordinal); + var sourcesByName = sourcesByTour.Values + .SelectMany(sources => sources) + .ToDictionary(source => source.Raw, StringComparer.Ordinal); + string[] allSources = sourcesByName.Keys.ToArray(); + + var aggregationTask = GetProductTourUsageToursAsync(projectId, utcStart, utcEnd, sourcesByTour, allSources); + var recentTask = FindAsync(query => ApplyProductTourUsageFilter(query, projectId, utcStart, utcEnd, allSources) + .SortDescending(ev => ev.Date), options => options.PageLimit(recentLimit)); + + await Task.WhenAll(aggregationTask, recentTask); + var recentEvents = (await recentTask).Documents + .Select(ev => ev.Source is not null && sourcesByName.TryGetValue(ev.Source, out var source) ? new ProductTourUsageEvent(ev, source) : null) + .OfType() + .ToArray(); + return new ProductTourUsageResult(await aggregationTask, recentEvents); + } + + private async Task> GetProductTourUsageToursAsync( + string projectId, + DateTime utcStart, + DateTime utcEnd, + IReadOnlyDictionary sourcesByTour, + string[] allSources) + { + var query = ApplyProductTourUsageFilter(NewQuery(), projectId, utcStart, utcEnd, allSources); + var options = ConfigureOptions(null); + await OnBeforeQueryAsync(query, options, typeof(PersistentEvent)); + await RefreshForConsistency(query, options); + + var search = (await CreateSearchDescriptorAsync(query, options)) + .Size(0) + .Aggregations(CreateProductTourAggregations(sourcesByTour)); + var response = await _client.SearchAsync(search); + _logger.LogRequest(response); + + if (!response.IsValidResponse) + throw new DocumentException($"Error getting product tour usage: {response.ElasticsearchServerError?.Error?.Reason}", response.ApiCallDetails.OriginalException); + + var tours = new List(); + foreach ((string tourName, ProductTourUsageSource[] sources) in sourcesByTour) + { + if (response.Aggregations is null + || !response.Aggregations.TryGetValue(tourName, out var aggregate) + || aggregate is not FilterAggregate filterAggregate + || filterAggregate.Aggregations is null) + continue; + + if (!filterAggregate.Aggregations.TryGetValue("sources", out var sourceAggregate) || sourceAggregate is not StringTermsAggregate sourceTerms) + continue; + + var buckets = new List(); + foreach (var bucket in sourceTerms.Buckets) + { + if (!bucket.Key.TryGetString(out string? sourceValue)) + continue; + + var source = sources.FirstOrDefault(source => String.Equals(source.Raw, sourceValue, StringComparison.Ordinal)); + if (source is null) + continue; + + long count = bucket.Aggregations is { } bucketAggregations + && bucketAggregations.TryGetValue("count", out var countAggregate) + && countAggregate is SumAggregate sum + ? Convert.ToInt64(sum.Value) + : bucket.DocCount; + DateTime? lastUtc = bucket.Aggregations is { } lastAggregations + && lastAggregations.TryGetValue("last", out var lastAggregate) + && lastAggregate is MaxAggregate max + ? max.ValueAsString is null ? null : DateTime.Parse(max.ValueAsString, null, System.Globalization.DateTimeStyles.RoundtripKind) + : null; + buckets.Add(new ProductTourUsageBucket(source, count, lastUtc)); + } + + long uniqueUsers = filterAggregate.Aggregations.TryGetValue("users", out var usersAggregate) && usersAggregate is CardinalityAggregate cardinality + ? Convert.ToInt64(cardinality.Value) + : 0; + if (buckets.Count > 0) + tours.Add(new ProductTourUsageTour(tourName, uniqueUsers, buckets)); + } + + return tours; + } + + private IDictionary CreateProductTourAggregations(IReadOnlyDictionary sourcesByTour) + { + string sourceField = ElasticIndex.MappingResolver.GetNonAnalyzedFieldName(InferField(ev => ev.Source))!; + string userPath = EventIndexExtensions.DataPath(Event.KnownDataKeys.UserInfo, user => user.Identity); + string userField = ElasticIndex.MappingResolver.GetNonAnalyzedFieldName(userPath) ?? userPath; + var aggregations = new Dictionary(StringComparer.Ordinal); + + foreach ((string tourName, ProductTourUsageSource[] sources) in sourcesByTour) + { + aggregations[tourName] = new Aggregation + { + Filter = new TermsQuery + { + Field = sourceField, + Terms = new TermsQueryField(sources.Select(source => (Elastic.Clients.Elasticsearch.FieldValue)source.Raw).ToArray()) + }, + Aggregations = new Dictionary + { + ["sources"] = new Aggregation + { + Terms = new TermsAggregation { Field = sourceField, Size = sources.Length }, + Aggregations = new Dictionary + { + ["count"] = new SumAggregation { Field = InferField(ev => ev.Count), Missing = 1 }, + ["last"] = new MaxAggregation { Field = InferField(ev => ev.Date) } + } + }, + ["users"] = new CardinalityAggregation { Field = userField } + } + }; + } + + return aggregations; + } + + private static IRepositoryQuery ApplyProductTourUsageFilter( + IRepositoryQuery query, + string projectId, + DateTime utcStart, + DateTime utcEnd, + string[] sources) + { + return query + .Project(projectId) + .FieldEquals(ev => ev.Type, Event.KnownTypes.FeatureUsage) + .FieldEquals(ev => ev.Source, sources) + .DateRange(utcStart, utcEnd, (PersistentEvent ev) => ev.Date) + .Index(utcStart, utcEnd); + } + + private static ProductTourUsageSource[] CreateProductTourSources(string tourName, int currentVersion) + { + return Enumerable.Range(1, currentVersion) + .SelectMany(version => ProductTours.TelemetryEvents.SelectMany(telemetryEvent => ProductTours.LaunchSources.Select(launchSource => + new ProductTourUsageSource( + ProductTours.CreateTelemetrySource(telemetryEvent, tourName, version, launchSource), + telemetryEvent, + tourName, + version, + launchSource)))) + .ToArray(); + } + public async Task GetPreviousAndNextEventIdsAsync(PersistentEvent ev, AppFilter? systemFilter = null, DateTime? utcStart = null, DateTime? utcEnd = null) { var previous = GetPreviousEventIdAsync(ev, systemFilter, utcStart, utcEnd); diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs index c7c2272cbc..9545942087 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IEventRepository.cs @@ -13,6 +13,7 @@ public interface IEventRepository : IRepositoryOwnedByOrganizationAndProject UpdateSessionStartLastActivityAsync(string id, DateTime lastActivityUtc, bool isSessionEnd = false, bool hasError = false, bool sendNotifications = true); Task RemoveAllAsync(string organizationId, string? clientIpAddress, DateTime? utcStart, DateTime? utcEnd, CommandOptionsDescriptor? options = null); Task RemoveAllByStackIdsAsync(string[] stackIds); + Task GetProductTourUsageAsync(string projectId, DateTime utcStart, DateTime utcEnd, int recentLimit = 500); } public static class EventRepositoryExtensions diff --git a/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs new file mode 100644 index 0000000000..b6fbc20df6 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/ProductTourUsageResult.cs @@ -0,0 +1,21 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; + +namespace Exceptionless.Core.Repositories; + +public sealed record ProductTourUsageResult( + IReadOnlyCollection Tours, + IReadOnlyCollection RecentEvents); + +public sealed record ProductTourUsageTour(string Name, long UniqueUsers, IReadOnlyCollection Buckets); + +public sealed record ProductTourUsageBucket(ProductTourUsageSource Source, long Count, DateTime? LastUtc); + +public sealed record ProductTourUsageEvent(PersistentEvent Event, ProductTourUsageSource Source); + +public sealed record ProductTourUsageSource( + string Raw, + ProductTourTelemetryEvent Event, + string TourName, + int Version, + ProductTourLaunchSource LaunchSource); diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index 7d6bbbbfe4..22bce2dc6e 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -87,6 +87,14 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); + endpoints.MapGet("api/v2/admin/product-tour-usage", GetProductTourUsageAsync) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, string planId) => (await mediator.InvokeAsync>(new AdminChangePlan(organizationId, planId, httpContext))).ToHttpResult(resultMapper)); @@ -130,4 +138,11 @@ private static EventSubmissionSettings CreateEventSubmissionSettings(bool? enabl bool configuredEnabled = !appOptions.EventSubmissionDisabled; return new EventSubmissionSettings(enabledOverride ?? configuredEnabled, configuredEnabled, enabledOverride.HasValue); } + + private static async Task GetProductTourUsageAsync( + IMediator mediator, + IMediatorResultMapper resultMapper, + DateTime? month = null, + int limit = 100) + => (await mediator.InvokeAsync>(new GetAdminProductTourUsage(month, limit))).ToHttpResult(resultMapper); } diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44ce3acd29..96f0464734 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Extensions; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Infrastructure; @@ -37,6 +38,27 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder } }); + group.MapPut("users/me/product-tours/{tourName:regex(^[a-z0-9]+(?:-[a-z0-9]+)*$):maxlength(64)}", async (string tourName, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProductTourProgress progress) + => (await mediator.InvokeAsync>(new UserMessages.UpdateCurrentUserProductTour(tourName, progress))).ToHttpResult(resultMapper)) + .Accepts(false, "application/json") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update current user product tour progress") + .WithMetadata(new EndpointDocumentation { + RequestBodyDescription = "The versioned product tour outcome.", + RequestBodyRequired = true, + ParameterDescriptions = new() { + ["tourName"] = "The stable product tour name.", + }, + ResponseDescriptions = new() { + ["400"] = "The request body is missing or malformed.", + ["422"] = "The product tour progress is invalid.", + ["404"] = "The current user could not be found.", + } + }); + group.MapGet("users/me/oauth-grants", async (IMediator mediator, IMediatorResultMapper resultMapper) => (await mediator.InvokeAsync>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper)) .Produces>() diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 0fe5e82b12..f9b3b1e8e1 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Messaging.Models; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Models.WorkItems; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; @@ -17,6 +18,8 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Migrations; +using Foundatio.Repositories.Models; +using Foundatio.Serializer; using Foundatio.Storage; using Foundatio.Mediator; @@ -38,6 +41,7 @@ public class AdminHandler( BillingPlans plans, IMigrationStateRepository migrationStateRepository, SampleDataService sampleDataService, + ITextSerializer serializer, TimeProvider timeProvider, ILoggerFactory loggerFactory) { @@ -78,7 +82,7 @@ public async Task> Handle(GetAdminStats message) public async Task> Handle(GetAdminAssistantUsage message) { var requestedMonth = message.Month ?? timeProvider.GetUtcNow().UtcDateTime; - var month = new DateTime(requestedMonth.Year, requestedMonth.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var month = requestedMonth.ToUniversalTime().StartOfMonth(); var results = await organizationRepository.FindAsync( query => query.FieldEquals(organization => organization.AssistantUsage.First().Date, month), options => options.SearchAfterPaging().PageLimit(500)); @@ -134,6 +138,50 @@ public async Task> Handle(GetAdminAssistantUsage message) rows.Take(limit).ToArray()); } + public async Task> Handle(GetAdminProductTourUsage message) + { + var requestedMonth = message.Month ?? timeProvider.GetUtcNow().UtcDateTime; + var month = requestedMonth.ToUniversalTime().StartOfMonth(); + var nextMonth = month.AddMonths(1); + int limit = Math.Clamp(message.Limit, 1, 500); + + var usage = await eventRepository.GetProductTourUsageAsync(appOptions.InternalProjectId, month, nextMonth, limit); + var tours = usage.Tours + .Select(tour => + { + long shown = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Shown); + long started = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Started); + long completed = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Completed); + long dismissed = SumEvent(tour.Buckets, ProductTourTelemetryEvent.Dismissed); + long decisionDenominator = started > 0 ? started : shown; + DateTime? lastRunUtc = tour.Buckets.Select(bucket => bucket.LastUtc).Max(); + + return new AdminProductTourSummary( + tour.Name, + shown, + started, + completed, + dismissed, + tour.UniqueUsers, + lastRunUtc, + CalculateRate(completed, decisionDenominator), + CalculateRate(dismissed, decisionDenominator)); + }) + .OrderByDescending(tour => tour.Started) + .ThenBy(tour => tour.Name, StringComparer.Ordinal) + .ToArray(); + + var recentActivity = usage.RecentEvents + .Select(item => CreateActivity(item.Event, item.Source)) + .Take(limit) + .ToArray(); + + return new AdminProductTourUsageResponse( + month, + tours, + recentActivity); + } + [HandlerEndpoint(HandlerMethod.Get, "migrations", Group = "Admin")] public async Task> Handle(GetAdminMigrations message) { @@ -172,6 +220,30 @@ public Task> Handle(GetAdminEcho message) }); } + private AdminProductTourActivity CreateActivity(PersistentEvent ev, ProductTourUsageSource source) + { + var user = ev.GetUserIdentity(serializer, _logger); + return new AdminProductTourActivity( + ev.Date.UtcDateTime, + source.Event, + source.LaunchSource, + source.TourName, + user?.Identity, + user?.Name, + source.Version, + ev.Count ?? 1); + } + + private static decimal? CalculateRate(long value, long denominator) + { + return denominator > 0 ? Decimal.Round(value / (decimal)denominator, 4) : null; + } + + private static long SumEvent(IEnumerable buckets, ProductTourTelemetryEvent telemetryEvent) + { + return buckets.Where(bucket => bucket.Source.Event == telemetryEvent).Sum(bucket => bucket.Count); + } + [HandlerEndpoint(HandlerMethod.Get, "assemblies", Group = "Admin")] public Task> Handle(GetAdminAssemblies message) { diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 92e9bd563e..2d0e4cdd60 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Repositories; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Api.Infrastructure; @@ -14,8 +15,9 @@ using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using Foundatio.Caching; -using Foundatio.Repositories; using Foundatio.Mediator; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; namespace Exceptionless.Web.Api.Handlers; @@ -49,6 +51,49 @@ public async Task> Handle(GetCurrentUser message) }; } + public async Task> Handle(UpdateCurrentUserProductTour message) + { + if (!ProductTours.IsKnown(message.TourName)) + return Result.Invalid(ValidationError.Create("tour_name", "Unknown product tour.")); + + if (!ProductTours.IsValid(message.TourName, message.Progress.Version)) + return Result.Invalid(ValidationError.Create("version", "The product tour version is not supported.")); + + ProductTourProgress? progress = null; + await repository.PatchAsync( + GetCurrentUserId(), + new ActionPatch(user => + { + user.ProductTours.TryGetValue(message.TourName, out var currentProgress); + if (!ShouldUpdateProductTourProgress(currentProgress, message.Progress)) + { + progress = currentProgress; + return false; + } + + progress = new ProductTourProgress + { + Status = message.Progress.Status!.Value, + UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime, + Version = message.Progress.Version + }; + user.ProductTours[message.TourName] = progress; + return true; + }), + options => options.Cache()); + + return progress is null ? Result.NotFound("User not found.") : progress; + } + + private static bool ShouldUpdateProductTourProgress(ProductTourProgress? current, UpdateProductTourProgress requested) + { + return current is null + || requested.Version > current.Version + || (requested.Version == current.Version + && current.Status is ProductTourStatus.Dismissed + && requested.Status is ProductTourStatus.Completed); + } + public async Task>> Handle(GetCurrentUserOAuthGrants message) { var tokens = new List(); diff --git a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs index df738edd85..43f3144691 100644 --- a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs @@ -3,6 +3,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetAdminSettings; public record GetAdminStats; public record GetAdminAssistantUsage(DateTime? Month, int Limit, HttpContext Context); +public record GetAdminProductTourUsage(DateTime? Month, int Limit); public record GetAdminMigrations; public record GetAdminEcho(HttpContext Context); public record GetAdminAssemblies; diff --git a/src/Exceptionless.Web/Api/Messages/UserMessages.cs b/src/Exceptionless.Web/Api/Messages/UserMessages.cs index 7cc329710b..7973ceae86 100644 --- a/src/Exceptionless.Web/Api/Messages/UserMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -6,6 +6,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetCurrentUser; public record GetCurrentUserOAuthGrants; public record RevokeCurrentUserOAuthGrant(string Id); +public record UpdateCurrentUserProductTour(string TourName, UpdateProductTourProgress Progress); public record GetUserById(string Id); public record GetUsersByOrganization(string OrganizationId, int Page, int Limit); public record UpdateUserMessage(string Id, Delta Changes); diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 8b3f0f6eaf..1451c7f25c 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -309,6 +309,15 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } + async updateProductTour(token: string, tourName: string, version: number, status: 'completed' | 'dismissed'): Promise { + const response = await this.request.put(this.url(`users/me/product-tours/${tourName}`), { + data: { status, version }, + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200], 'update product tour'); + } + async waitForCurrentUserDeleted(token: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getCurrentUser(token)), diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 154144230d..380578c279 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -43,6 +43,7 @@ export interface E2ESecondaryProject { interface E2EFixtures { e2eApi: E2EApiClient; e2eCleanupPassword: string; + e2eDismissProductTourWelcome: boolean; e2eScenario: E2EScenario; e2eSecondaryOrganization: E2ESecondaryOrganization; e2eSecondaryProject: E2ESecondaryProject; @@ -57,7 +58,9 @@ export const test = base.extend({ e2eCleanupPassword: [E2E_TEST_PASSWORD, { option: true }], - e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eUseGeneratedUser, page }, use, testInfo) => { + e2eDismissProductTourWelcome: [true, { option: true }], + + e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eDismissProductTourWelcome, e2eUseGeneratedUser, page }, use, testInfo) => { const run = createRunName(e2eApi.environment.runId, testInfo); const userName = `Playwright User ${run}`; const email = `playwright-${run}@exceptionless.test`.toLowerCase(); @@ -85,6 +88,9 @@ export const test = base.extend({ const project = await e2eApi.createProject(userToken, organization.id, projectName); projectId = project.id; const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); + if (e2eDismissProductTourWelcome) { + await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); + } await page.addInitScript( ({ organizationId, token }) => { diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts index 729f62728e..b192730f74 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts @@ -4,6 +4,7 @@ import { expect, test } from '../fixtures/e2e-test'; test('dashboard charts stay mounted while list data refreshes', async ({ e2eApi, page }) => { const userToken = await e2eApi.login(); + await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); const organizations = await e2eApi.getOrganizations(userToken); const organizationId = organizations[0]?.id; expect(organizationId).toBeTruthy(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts new file mode 100644 index 0000000000..04496a2594 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -0,0 +1,271 @@ +import type { Page, Request, Response } from '@playwright/test'; + +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { createRepresentativeEvent } from '../support/synthetic-event'; + +test.use({ actionTimeout: 15_000, e2eUseGeneratedUser: true }); + +test.describe('first-run welcome', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + test('Browse Guides persists before the catalog opens', async ({ e2eScenario, page }) => { + await test.step(`show the first-run prompt for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); + }); + + const persisted = page.waitForResponse(isSuccessfulTourProgress('welcome')); + await page.getByRole('dialog', { name: 'Welcome to Exceptionless' }).getByRole('button', { name: 'Browse Guides' }).click(); + await persisted; + + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + await expect(catalog).toBeVisible(); + await catalog.getByRole('button', { name: 'Close' }).click(); + await page.reload(); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); +}); + +test.describe('shell and identity checkpoints', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + test('supports responsive resume and never carries checkpoints across identities', async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => { + test.setTimeout(240_000); + const progressWrites: string[] = []; + page.on('request', (request) => { + if (request.method() === 'PUT' && request.url().includes('/api/v2/users/me/product-tours/')) { + progressWrites.push(new URL(request.url()).pathname); + } + }); + + await test.step('closing the welcome persists dismissal', async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeVisible(); + const dismissed = page.waitForResponse(isSuccessfulTourProgress('welcome')); + await page.keyboard.press('Escape'); + await dismissed; + await expect(page.getByRole('dialog', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); + + await test.step('the shell tour renders on mobile and resumes on desktop with reduced motion', async () => { + await page.setViewportSize({ height: 844, width: 390 }); + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + await expect(page.locator('[data-tour="app-navigation"]')).toBeVisible(); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ height: 900, width: 1440 }); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + await page.reload(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + + const dismissed = page.waitForResponse(isSuccessfulTourProgress('ui-overview')); + await tour.getByRole('button', { name: 'Close' }).click(); + await dismissed; + await expectProductTourSession(page, false); + }); + + await test.step('an organization change clears an active checkpoint without recording progress', async () => { + await mockAssistantAccess(page); + await page.reload(); + await startTourFromCommand(page, 'Meet Exie'); + await expectProductTourSession(page, true); + const writesBeforeSwitch = progressWrites.length; + + const identityTab = await page.context().newPage(); + await identityTab.goto('/next/stack'); + await identityTab.evaluate((organizationId) => { + window.localStorage.setItem('organization', JSON.stringify(organizationId)); + }, e2eSecondaryOrganization.organizationId); + await identityTab.close(); + await expectProductTourSession(page, false); + expect(progressWrites).toHaveLength(writesBeforeSwitch); + }); + + await test.step('logout clears an active checkpoint without recording progress', async () => { + await startTourFromCommand(page, 'Meet Exie'); + await expectProductTourSession(page, true); + const writesBeforeLogout = progressWrites.length; + + await page.getByRole('button', { name: new RegExp(e2eScenario.userName) }).dispatchEvent('click'); + await page.getByRole('menuitem', { name: 'Log Out' }).dispatchEvent('click'); + await expect(page).toHaveURL(/\/next\/login/); + await expectProductTourSession(page, false); + expect(progressWrites).toHaveLength(writesBeforeLogout); + + e2eScenario.userToken = await e2eApi.login(e2eScenario.email, E2E_TEST_PASSWORD); + }); + }); +}); + +test('domain workflows advance only on real success', async ({ e2eApi, e2eScenario, page }) => { + test.setTimeout(300_000); + + await test.step('project configuration advances after creation and the first event', async () => { + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Configure a project'); + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + + const projectName = `Tour Project ${e2eScenario.run}`; + await page.getByLabel('Project Name', { exact: true }).fill(projectName); + await page.getByRole('button', { name: 'Continue to Client Setup' }).click(); + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + const projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + expect(projectId).toBeTruthy(); + + await page.locator('[data-tour="project-configure-platform"]').click(); + await page.getByRole('option', { name: 'Browser applications' }).click(); + await page.locator('[data-product-tour-inline="configure-project"]').getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByText('Waiting for your first event')).toBeVisible(); + + try { + const token = await e2eApi.getProjectDefaultToken(e2eScenario.userToken, projectId!); + await e2eApi.submitEvent( + projectId!, + token.id, + createRepresentativeEvent({ + appUrl: e2eApi.environment.appUrl, + message: e2eScenario.message, + referenceId: e2eScenario.referenceId, + runId: e2eApi.environment.runId + }) + ); + await expect(page).toHaveURL(/\/next\/event/); + await expectProductTourSession(page, false); + } finally { + await e2eApi.deleteProject(e2eScenario.userToken, projectId!); + await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!); + } + }); + + await test.step('saved-view progress retry never repeats the successful POST', async () => { + let createRequests = 0; + let progressRequests = 0; + const countSavedViewCreation = (request: Request) => { + const path = new URL(request.url()).pathname; + if (request.method() === 'POST' && /^\/api\/v2\/organizations\/[^/]+\/saved-views$/.test(path)) createRequests += 1; + }; + const progressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/create-saved-view'; + page.on('request', countSavedViewCreation); + await page.route(progressRoute, async (route) => { + progressRequests += 1; + if (progressRequests === 1) { + await route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 }); + return; + } + + await route.continue(); + }); + + try { + await page.goto('/next/event'); + await startTourFromCommand(page, 'Create a saved view'); + await expectProductTourSession(page, true); + const tour = page.locator('.driver-popover'); + await tour.getByRole('button', { name: 'Continue' }).click(); + await tour.getByRole('button', { name: 'Continue' }).click(); + + await page.getByLabel('Name', { exact: true }).fill(`Tour View ${e2eScenario.run}`); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + await expect(page.getByText('Retry guide completion')).toBeVisible(); + expect(createRequests).toBe(1); + + await page.reload(); + await expect(page.getByRole('button', { name: 'Retry guide completion' })).toBeVisible(); + const completed = page.waitForResponse(isSuccessfulTourProgress('create-saved-view')); + await page.getByRole('button', { name: 'Retry guide completion' }).click(); + await completed; + await expect.poll(() => createRequests).toBe(1); + await expectProductTourSession(page, false); + } finally { + page.off('request', countSavedViewCreation); + await page.unroute(progressRoute); + } + }); + + await test.step('investigation advances when a real error opens', async () => { + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + await page.goto('/next/event?time=all&type=error'); + await expect(page.getByText(e2eScenario.message).first()).toBeVisible({ timeout: 30_000 }); + await startTourFromCommand(page, 'Investigate an error'); + await page.locator('.driver-popover').getByRole('button', { name: 'Continue' }).click(); + await page.locator('tr').filter({ hasText: e2eScenario.message }).first().click(); + const callout = page.locator('[data-product-tour-inline="investigate-error"]'); + await expect(callout.getByText('Understand the grouped issue')).toBeVisible(); + for (const title of ['Triage deliberately', 'Inspect the occurrence', 'Begin with the overview', 'Compare every occurrence']) { + await callout.getByRole('button', { name: 'Continue' }).click(); + await expect(callout.getByText(title)).toBeVisible(); + } + + const completed = page.waitForResponse(isSuccessfulTourProgress('investigate-error')); + await callout.getByRole('button', { name: 'Finish guide' }).click(); + await completed; + await expectProductTourSession(page, false); + await page.reload(); + await expect(page.locator('[data-product-tour-inline="investigate-error"]')).toBeHidden(); + }); + + await test.step('Exie opens context without provider submission', async () => { + await mockAssistantAccess(page); + let chatRequests = 0; + const countChatRequest = (request: Request) => { + if (new URL(request.url()).pathname === '/api/v2/assistant/chat') chatRequests += 1; + }; + page.on('request', countChatRequest); + + try { + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Meet Exie'); + const tour = page.locator('.driver-popover'); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('You control every request')).toBeVisible(); + expect(chatRequests).toBe(0); + } finally { + page.off('request', countChatRequest); + } + }); +}); + +async function expectProductTourSession(page: Page, present: boolean): Promise { + const assertion = expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))); + if (present) { + await assertion.not.toBeNull(); + } else { + await assertion.toBeNull(); + } +} + +function isSuccessfulTourProgress(tourName: string) { + return (response: Response): boolean => { + const path = new URL(response.url()).pathname; + return response.request().method() === 'PUT' && path === `/api/v2/users/me/product-tours/${tourName}` && response.status() === 200; + }; +} + +async function mockAssistantAccess(page: Page): Promise { + await page.route( + (url) => url.pathname === '/api/v2/assistant/access', + (route) => route.fulfill({ json: { enabled: true, has_access: true, message: null, upgrade_required: false } }) + ); +} + +async function startTourFromCommand(page: Page, title: string): Promise { + const announcementStart = page.getByRole('button', { name: 'See how it works' }); + if (title === 'Meet Exie' && (await announcementStart.isVisible())) { + await announcementStart.click(); + return; + } + + await page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await page.getByRole('dialog').getByText(title, { exact: true }).click(); +} diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 23a4f6bd0e..483f1f2f02 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.14", + "driver.js": "^1.8.0", "layerchart": "^2.3.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", @@ -6069,6 +6070,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/driver.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index f3fc1b0a20..f9d84a2dbd 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -94,6 +94,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.14", + "driver.js": "^1.8.0", "layerchart": "^2.3.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 63f51aab4f..1d35d410b5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -6,6 +6,7 @@ import type { AdminAssistantSettings, AdminAssistantUsage, AdminEventSubmissionSettings, + AdminProductTourUsage, AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, @@ -32,6 +33,7 @@ export const queryKeys = { eventSubmissionSettings: ['admin', 'event-submission-settings'] as const, migrations: ['admin', 'migrations'] as const, oauthApplications: ['admin', 'oauth-applications'] as const, + productTourUsage: (month: string) => ['admin', 'product-tour-usage', month] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, stats: ['admin', 'stats'] as const }; @@ -98,6 +100,29 @@ export function getAdminAssistantUsageQuery(month: () => string) { })); } +export function getAdminProductTourUsageQuery(month: () => string) { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/product-tour-usage', { + params: { + limit: 100, + month: `${month()}-01` + }, + signal + }); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + queryKey: queryKeys.productTourUsage(month()), + staleTime: 60 * 1000 + })); +} + export function getAdminStatsQuery() { return createQuery(() => ({ queryFn: async ({ signal }: { signal: AbortSignal }) => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index e5bd45b444..568924dad6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -1,12 +1,13 @@ import type { AssistantModelSettings, CountResult, - EventSubmissionSettings, UpdateAssistantEnabledSettings, UpdateAssistantSettings, UpdateEventSubmissionSettings } from '$generated/api'; +export type { EventSubmissionSettings as AdminEventSubmissionSettings, AdminProductTourUsageResponse as AdminProductTourUsage } from '$generated/api'; + export enum MigrationType { Versioned = 0, VersionedAndResumable = 1, @@ -49,8 +50,6 @@ export type AdminAssistantUsage = { turns: number; }; -export type AdminEventSubmissionSettings = EventSubmissionSettings; - export type AdminStats = { events: CountResult; organizations: CountResult; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte index 916b8454b4..7001b41ba7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte @@ -508,6 +508,7 @@ -
+ + +

Stack

{#if event?.stack_id} -
+ + +

Event

@@ -355,6 +360,7 @@ {#if event?.stack_id} +
+ {/each} + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte new file mode 100644 index 0000000000..3fde333876 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte @@ -0,0 +1,48 @@ + + + + + +
+
+ Welcome to Exceptionless + Take a short guided tour now, or browse the guides whenever you need them. +
+ +
+

Recommended: {recommended.title}

+

{recommended.description}

+
+ + + +
+ + +
+
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts new file mode 100644 index 0000000000..a3744dc382 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-welcome-dialog.svelte.test.ts @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import ProductTourWelcomeDialog from './product-tour-welcome-dialog.svelte'; + +const recommended = { + availability: vi.fn(() => ({ available: true })), + currentAvailability: { available: true }, + description: 'Learn navigation and search.', + initialCheckpoint: 'navigation' as const, + keywords: ['navigation'], + name: 'ui-overview' as const, + startingRoute: vi.fn(() => '/next'), + title: 'Explore Exceptionless', + version: 1 +}; + +describe('ProductTourWelcomeDialog', () => { + it('records dismissal from Escape', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + const onStart = vi.fn(); + render(ProductTourWelcomeDialog, { onBrowse, onDismiss, onStart, open: true, recommended }); + + await fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onBrowse).not.toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledOnce(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it('provides Browse Guides and Skip choices', async () => { + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + render(ProductTourWelcomeDialog, { onBrowse, onDismiss, onStart: vi.fn(), open: true, recommended }); + + await fireEvent.click(screen.getByRole('button', { name: 'Browse Guides' })); + expect(onBrowse).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte new file mode 100644 index 0000000000..910b93917d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-feature-announcement.svelte @@ -0,0 +1,44 @@ + + +{#if open} + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte new file mode 100644 index 0000000000..735ee4de5b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -0,0 +1,268 @@ + + + + +{#if exieAnnouncementOpen && assistantAccess} + +{/if} + + startTour(name, catalogSource)} /> + +{#if checkpoint && (checkpoint.tourName === 'meet-exie' || checkpoint.tourName === 'ui-overview')} + {#key checkpoint} + + {/key} +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte new file mode 100644 index 0000000000..325b931d75 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-inline-callout.svelte @@ -0,0 +1,30 @@ + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte new file mode 100644 index 0000000000..cc38a9ab01 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -0,0 +1,119 @@ + + +{#if spotlight && (!isAnyOverlayOpen || checkpoint.tourName === 'meet-exie')} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte new file mode 100644 index 0000000000..a00e3de4e7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -0,0 +1,75 @@ + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts new file mode 100644 index 0000000000..65b7d7b3f1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -0,0 +1,35 @@ +import { ProductTourStatus } from '$generated/api'; +import { describe, expect, it } from 'vitest'; + +import { isProductTourSetupRoute, shouldOfferProductTourAnnouncement, shouldOfferProductTourWelcome } from './eligibility'; + +describe('product tour setup routes', () => { + it.each(['/(app)/organization/add', '/(app)/project/add', '/(app)/project/[projectId]/configure'])('suppresses automatic tours on %s', (routeId) => { + expect(isProductTourSetupRoute(routeId)).toBe(true); + }); + + it('allows automatic tours after setup', () => { + expect(isProductTourSetupRoute('/(app)/stack')).toBe(false); + expect(isProductTourSetupRoute(null)).toBe(false); + }); +}); + +describe('product tour welcome eligibility', () => { + it('offers legacy users and a newer welcome version', () => { + expect(shouldOfferProductTourWelcome(undefined, 1)).toBe(true); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 2)).toBe(true); + }); + + it('suppresses both explicit Start and Skip outcomes for the current version', () => { + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Completed, updated_utc: '', version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourWelcome({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); + }); +}); + +describe('product tour feature announcement eligibility', () => { + it('offers a new announcement version until explicitly recorded', () => { + expect(shouldOfferProductTourAnnouncement(undefined, 1)).toBe(true); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Dismissed, updated_utc: '', version: 1 }, 1)).toBe(false); + expect(shouldOfferProductTourAnnouncement({ status: ProductTourStatus.Completed, updated_utc: '', version: 2 }, 1)).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts new file mode 100644 index 0000000000..882d52d3f8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -0,0 +1,15 @@ +import type { ProductTourProgress } from '$features/users/models'; + +const SETUP_ROUTE_IDS = new Set(['/(app)/organization/add', '/(app)/project/[projectId]/configure', '/(app)/project/add']); + +export function isProductTourSetupRoute(routeId: null | string): boolean { + return !!routeId && SETUP_ROUTE_IDS.has(routeId); +} + +export function shouldOfferProductTourAnnouncement(progress: ProductTourProgress | undefined, announcementVersion: number): boolean { + return !progress || progress.version < announcementVersion; +} + +export function shouldOfferProductTourWelcome(progress: ProductTourProgress | undefined, welcomeVersion: number): boolean { + return !progress || progress.version < welcomeVersion; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts new file mode 100644 index 0000000000..a14662905a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.svelte.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './types'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'choose-error', + organizationId: 'organization-id', + phase: { type: 'active' }, + source: 'command-palette', + tourName: 'investigate-error', + userId: 'user-id', + version: 1 +}; + +describe('product tour session', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('round-trips an explicit checkpoint', () => { + writeProductTourSession(checkpoint); + expect(readProductTourSession()).toEqual(checkpoint); + clearProductTourSession(); + expect(sessionStorage).toHaveLength(0); + }); + + it.each([ + '{not-json', + JSON.stringify({ ...checkpoint, tourName: 'unknown-tour' }), + JSON.stringify({ ...checkpoint, checkpointName: 'unknown-step' }), + JSON.stringify({ ...checkpoint, source: 'unknown-source' }), + JSON.stringify({ ...checkpoint, version: 0 }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created' } }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-created', viewId: 'view-id' } }), + JSON.stringify({ ...checkpoint, phase: { type: 'saved-view-loaded', viewId: 'view-id' } }), + JSON.stringify({ ...checkpoint, userId: 42 }) + ])('clears malformed or unknown stored state: %s', (value) => { + sessionStorage.setItem('exceptionless.product-tour', value); + expect(readProductTourSession()).toBeUndefined(); + expect(sessionStorage).toHaveLength(0); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts new file mode 100644 index 0000000000..0b52603cc7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -0,0 +1,77 @@ +import { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; + +import type { ProductTourCheckpoint, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; + +import { PRODUCT_TOUR_CHECKPOINTS } from './types'; + +const SESSION_KEY = 'exceptionless.product-tour'; +const SOURCES = new Set(Object.values(ProductTourLaunchSourceContract)); + +export function clearProductTourSession(storage: Pick = sessionStorage): void { + storage.removeItem(SESSION_KEY); +} + +export function readProductTourSession(storage: Pick = sessionStorage): ProductTourCheckpoint | undefined { + try { + const value = storage.getItem(SESSION_KEY); + if (!value) return undefined; + + const candidate: unknown = JSON.parse(value); + if (!isProductTourCheckpoint(candidate)) { + clearProductTourSession(storage); + return undefined; + } + + return candidate; + } catch { + clearProductTourSession(storage); + return undefined; + } +} + +export function writeProductTourSession(checkpoint: ProductTourCheckpoint, storage: Pick = sessionStorage): void { + storage.setItem(SESSION_KEY, JSON.stringify(checkpoint)); +} + +function isPhase(value: unknown, tourName: string, checkpointName: unknown): value is ProductTourPhase { + if (!isRecord(value) || typeof value.type !== 'string') return false; + if (value.type === 'active') return true; + return ( + tourName === 'create-saved-view' && + checkpointName === 'view-created' && + (value.type === 'saved-view-created' || value.type === 'saved-view-loaded') && + typeof value.viewId === 'string' && + !!value.viewId + ); +} + +function isProductTourCheckpoint(value: unknown): value is ProductTourCheckpoint { + if ( + !isRecord(value) || + typeof value.userId !== 'string' || + !value.userId || + typeof value.tourName !== 'string' || + typeof value.version !== 'number' || + !Number.isSafeInteger(value.version) || + value.version < 1 + ) + return false; + if (value.organizationId !== undefined && typeof value.organizationId !== 'string') return false; + if (!isProductTourLaunchSource(value.source) || !isProductTourName(value.tourName)) return false; + + const checkpoints: readonly string[] = PRODUCT_TOUR_CHECKPOINTS[value.tourName]; + if (typeof value.checkpointName !== 'string' || !checkpoints.includes(value.checkpointName)) return false; + return isPhase(value.phase, value.tourName, value.checkpointName); +} + +function isProductTourLaunchSource(value: unknown): value is ProductTourLaunchSource { + return typeof value === 'string' && SOURCES.has(value); +} + +function isProductTourName(value: string): value is ProductTourName { + return Object.hasOwn(PRODUCT_TOUR_CHECKPOINTS, value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts new file mode 100644 index 0000000000..d1d4d2f6f7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './types'; + +import { productTourCheckpoint } from './state.svelte'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'navigation', + organizationId: 'organization-id', + phase: { type: 'active' }, + source: 'catalog', + tourName: 'ui-overview', + userId: 'user-id', + version: 1 +}; + +describe('product tour checkpoint store', () => { + beforeEach(() => productTourCheckpoint.clear()); + + it('does not let stale work advance or clear a newer tour', () => { + const first = productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + checkpoint.source, + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); + const second = productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + 'help-menu', + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); + + expect(productTourCheckpoint.advance(first, 'command-search')).toBeUndefined(); + expect(productTourCheckpoint.clear(first)).toBe(false); + expect(productTourCheckpoint.current).toBe(second); + }); + + it('clears a checkpoint restored for another identity', () => { + productTourCheckpoint.start( + checkpoint.tourName, + checkpoint.checkpointName, + checkpoint.source, + checkpoint.userId, + checkpoint.version, + checkpoint.organizationId + ); + productTourCheckpoint.clear(); + sessionStorage.setItem('exceptionless.product-tour', JSON.stringify(checkpoint)); + + expect(productTourCheckpoint.restore('another-user', 'organization-id')).toBeUndefined(); + expect(sessionStorage.getItem('exceptionless.product-tour')).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts new file mode 100644 index 0000000000..7ba2fcb1d4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -0,0 +1,85 @@ +import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourLaunchSource, ProductTourName, ProductTourPhase } from './types'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +class ProductTourCheckpointStore { + current = $state.raw(); + + advance( + expected: ProductTourCheckpoint, + checkpointName: ProductTourCheckpointName, + phase: ProductTourPhase = { + type: 'active' + }, + organizationId = expected.organizationId + ): ProductTourCheckpoint | undefined { + if (this.current !== expected) { + return undefined; + } + const next = { + ...expected, + checkpointName, + organizationId, + phase + } as ProductTourCheckpoint; + return this.save(next); + } + + clear(expected?: ProductTourCheckpoint): boolean { + if (expected && this.current !== expected) { + return false; + } + this.current = undefined; + clearProductTourSession(); + return true; + } + + restore(userId: string, organizationId?: string): ProductTourCheckpoint | undefined { + if (this.current) { + return this.current; + } + + const stored = readProductTourSession(); + if (!stored) { + return undefined; + } + + if (stored.userId !== userId || stored.organizationId !== organizationId) { + clearProductTourSession(); + return undefined; + } + + this.current = stored; + return stored; + } + + start( + tourName: Name, + checkpointName: ProductTourCheckpointName, + source: ProductTourLaunchSource, + userId: string, + version: number, + organizationId?: string + ): ProductTourCheckpoint { + const checkpoint = { + checkpointName, + organizationId, + phase: { + type: 'active' + }, + source, + tourName, + userId, + version + } as ProductTourCheckpoint; + return this.save(checkpoint); + } + + private save(checkpoint: ProductTourCheckpoint): ProductTourCheckpoint { + this.current = checkpoint; + writeProductTourSession(checkpoint); + return checkpoint; + } +} + +export const productTourCheckpoint = new ProductTourCheckpointStore(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts new file mode 100644 index 0000000000..a7ff2b5165 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProductTourTelemetryEvent } from './telemetry'; + +describe('product tour telemetry', () => { + it('records stable lifecycle events without resource data', () => { + expect(buildProductTourTelemetryEvent('started', 'ui-overview', 1, 'command-palette')).toBe('product-tour.started.ui-overview.v1.command-palette'); + }); + + it('rejects invalid versions', () => { + expect(() => buildProductTourTelemetryEvent('started', 'meet-exie', 0, 'catalog')).toThrow(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts new file mode 100644 index 0000000000..8eeb6df23d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -0,0 +1,18 @@ +import type { ProductTourTelemetryEvent as ProductTourTelemetryEventContract } from '$generated/api'; + +import type { ProductTourKey, ProductTourLaunchSource } from './types'; + +export type ProductTourTelemetryEvent = `${ProductTourTelemetryEventContract}`; + +export function buildProductTourTelemetryEvent( + event: ProductTourTelemetryEvent, + name: ProductTourKey, + version: number, + source: ProductTourLaunchSource +): string { + if (!Number.isSafeInteger(version) || version < 1) { + throw new Error('Product tour telemetry requires a positive version.'); + } + + return ['product-tour', event, name, `v${version}`, source].join('.'); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts new file mode 100644 index 0000000000..87d794902b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -0,0 +1,61 @@ +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewProject } from '$features/projects/models'; +import type { ProductTourProgress } from '$features/users/models'; +import type { ProductTourLaunchSource as ProductTourLaunchSourceContract } from '$generated/api'; + +export const PRODUCT_TOUR_CHECKPOINTS = { + 'configure-project': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions', 'wait-for-event'], + 'create-saved-view': ['open-view-menu', 'review-settings', 'name-view', 'private-view', 'save-view', 'view-created'], + 'investigate-error': ['filter-errors', 'choose-error', 'stack-summary', 'stack-triage', 'event-occurrence', 'tab-overview', 'filter-stack-events'], + 'meet-exie': ['open-exie', 'exie-context'], + 'ui-overview': ['navigation', 'command-search', 'saved-views', 'exie', 'help'] +} as const; + +export interface ProductTourAvailability { + available: boolean; + reason?: string; +} +export type ProductTourCheckpoint = Name extends ProductTourName + ? { + checkpointName: ProductTourCheckpointName; + organizationId?: string; + phase: ProductTourPhase; + source: ProductTourLaunchSource; + tourName: Name; + userId: string; + version: number; + } + : never; +export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[Name][number]; +export interface ProductTourContext { + assistantAccess?: AssistantAccess; + errorEventAvailability: 'available' | 'empty' | 'error' | 'loading'; + isSetupPage: boolean; + organizationId?: string; + pathname: string; + projects: Pick[]; +} +export interface ProductTourDefinition { + availability: (context: ProductTourContext) => ProductTourAvailability; + description: string; + initialCheckpoint: ProductTourCheckpointName; + keywords: readonly string[]; + name: Name; + startingRoute: (context: ProductTourContext) => string; + title: string; +} + +export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourName; + +export type ProductTourLaunchSource = `${ProductTourLaunchSourceContract}`; + +export interface ProductTourListItem extends ProductTourDefinition { + currentAvailability: ProductTourAvailability; + progress?: ProductTourProgress; + version: number; +} + +export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; + +export type ProductTourPhase = + (Name extends 'create-saved-view' ? { type: 'saved-view-created' | 'saved-view-loaded'; viewId: string } : never) | { type: 'active' }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 95fc6808ec..4c5d0cb627 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -19,6 +19,7 @@ } from '../slugs'; interface Props { + defaultPrivate?: boolean; duplicateView?: SavedView; onClose: () => void; onLoadView: (view: SavedView) => void; @@ -28,7 +29,7 @@ saving: boolean; } - let { duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); + let { defaultPrivate = false, duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); let saveName = $state(''); let saveSlug = $state(''); @@ -87,7 +88,7 @@ saveName = ''; saveSlug = ''; isSlugDirty = false; - isPrivate = false; + isPrivate = defaultPrivate; attemptedSubmit = false; } }); @@ -115,8 +116,15 @@ } - - + { + if (!nextOpen) { + onClose(); + } + }} +> + Save View Save the current view configuration for quick access. @@ -146,6 +154,7 @@
{visibleSlugError}

{/if}
-
+
Only visible to you @@ -185,8 +194,8 @@
- - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 2cee06835d..74ad57afcf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -15,6 +15,7 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; + import CreateSavedViewTour from '$features/product-tours/components/create-saved-view-tour.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { getMeQuery } from '$features/users/api.svelte'; import Building2 from '@lucide/svelte/icons/building-2'; @@ -69,7 +70,7 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => Promise; - onLoadView: (view: SavedView) => void; + onLoadView: (view: SavedView) => Promise | void; onResetToSaved: () => void; onSavedViewUpdated: (view: SavedView) => void; savedViews: SavedView[]; @@ -120,6 +121,7 @@ let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); + let createSavedViewTour = $state(); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -259,6 +261,11 @@ return; } + const tour = createSavedViewTour; + if (tour && !tour.validateSave(isPrivate)) { + return; + } + const filterDefinitions = serializeFilters(filters); const body: NewSavedView = { columns: getSavedColumnSettings(), @@ -277,8 +284,9 @@ try { const result = await createMutation.mutateAsync(body); + const tourCompletion = tour ? tour.created(result) : onLoadView(result); isSaveDialogOpen = false; - onLoadView(result); + await tourCompletion; toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -393,7 +401,7 @@ {#snippet child({ props })} - {/snippet} - + Saved View {#if activeView} @@ -494,15 +502,25 @@ {#if isSaveDialogOpen} (isSaveDialogOpen = false)} + onClose={() => createSavedViewTour?.closed()} {onLoadView} /> {/if} + (isMenuOpen = false)} + openMenu={() => (isMenuOpen = true)} + openSaveDialog={() => (isSaveDialogOpen = true)} + {onLoadView} + {savedViews} +/> + {#if isRenameDialogOpen && activeView} Promise; - handleLoadView: (view: SavedView) => void; + handleLoadView: (view: SavedView) => Promise; handleResetToSaved: () => void; handleSavedViewUpdated: (view: SavedView) => void; hydratedSavedViewId: string | undefined; @@ -1199,9 +1199,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }) ); - function handleLoadView(view: SavedView) { + async function handleLoadView(view: SavedView): Promise { if (options.baseHref) { - goto(savedViewHref(view)); + await goto(savedViewHref(view)); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte index a4122d895a..ce02f11c3f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte @@ -40,6 +40,7 @@ > @@ -184,10 +185,12 @@
- - - - +
+ + + + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index 4747a521fa..e4feb3e218 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -7,7 +7,16 @@ import { fetchApiJson } from '$features/shared/api/api.svelte'; import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; -import type { OAuthGrant, UpdateEmailAddressResult, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; +import type { + OAuthGrant, + ProductTourProgress, + UpdateEmailAddressResult, + UpdateProductTourProgress, + UpdateUser, + UpdateUserEmailAddress, + ViewCurrentUser, + ViewUser +} from './models'; export async function invalidateUserQueries(queryClient: QueryClient, message: WebSocketMessageValue<'UserChanged'>) { const { id } = message; @@ -41,6 +50,7 @@ export const queryKeys = { organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const, patchUser: (id: string | undefined) => [...queryKeys.id(id), 'patch'] as const, postEmailAddress: (id: string | undefined) => [...queryKeys.idEmailAddress(id), 'update'] as const, + productTour: (tourName: string | undefined) => [...queryKeys.me(), 'product-tours', tourName] as const, type: ['User'] as const }; @@ -68,6 +78,11 @@ export interface PostEmailAddressRequest { }; } +export interface PutCurrentUserProductTourRequest { + progress: UpdateProductTourProgress; + tourName: string; +} + export interface ResendVerificationEmailRequest { route: { id: string | undefined; @@ -260,6 +275,40 @@ export function postEmailAddress(request: PostEmailAddressRequest) { })); } +export function putCurrentUserProductTour() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ progress, tourName }) => { + const client = useFetchClient(); + const response = await client.putJSON(`users/me/product-tours/${tourName}`, progress); + + if (!response.ok) { + throw response.problem; + } + + return response.data!; + }, + mutationKey: queryKeys.productTour(undefined), + onSuccess: (progress, { tourName }) => { + const currentUser = queryClient.getQueryData(queryKeys.me()); + if (!currentUser) { + return; + } + + const updatedUser = { + ...currentUser, + product_tours: { + ...currentUser.product_tours, + [tourName]: progress + } + }; + queryClient.setQueryData(queryKeys.me(), updatedUser); + queryClient.setQueryData(queryKeys.id(currentUser.id), updatedUser); + } + })); +} + export function resendVerificationEmail(request: ResendVerificationEmailRequest) { return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.id, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts index a262d71122..67d005712d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts @@ -1,4 +1,12 @@ -export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; +export { ProductTourStatus } from '$generated/api'; +export type { + ViewOAuthGrant as OAuthGrant, + ProductTourProgress, + UpdateEmailAddressResult, + UpdateProductTourProgress, + ViewCurrentUser, + ViewUser +} from '$generated/api'; export interface InviteUserForm { email: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index d009f96c39..197143e908 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,6 +7,26 @@ export enum StackStatus { Discarded = "discarded", } +export enum ProductTourTelemetryEvent { + Completed = "completed", + Dismissed = "dismissed", + Shown = "shown", + Started = "started", +} + +export enum ProductTourStatus { + Completed = "completed", + Dismissed = "dismissed", +} + +export enum ProductTourLaunchSource { + Automatic = "automatic", + Catalog = "catalog", + CommandPalette = "command-palette", + FeatureAnnouncement = "feature-announcement", + HelpMenu = "help-menu", +} + export enum BillingStatus { Trialing = 0, Active = 1, @@ -73,6 +93,47 @@ export interface AdminAssistantUsageResponse { organizations: AdminAssistantOrganizationUsage[]; } +export interface AdminProductTourActivity { + /** @format date-time */ + date_utc: string; + event: ProductTourTelemetryEvent; + launch_source: ProductTourLaunchSource; + tour_name: string; + user_identity?: null | string; + user_name?: null | string; + /** @format int32 */ + version: number; + /** @format int64 */ + count: number; +} + +export interface AdminProductTourSummary { + name: string; + /** @format int64 */ + shown: number; + /** @format int64 */ + started: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + dismissed: number; + /** @format int64 */ + unique_users: number; + /** @format date-time */ + last_run_utc?: null | string; + /** @format double */ + completion_rate?: null | number; + /** @format double */ + dismissal_rate?: null | number; +} + +export interface AdminProductTourUsageResponse { + /** @format date-time */ + month: string; + tours: AdminProductTourSummary[]; + recent_activity: AdminProductTourActivity[]; +} + export interface AssistantAccessResponse { enabled: boolean; has_access: boolean; @@ -494,6 +555,14 @@ export interface ProblemDetails { instance?: null | string; } +export interface ProductTourProgress { + status: ProductTourStatus; + /** @format date-time */ + updated_utc: string; + /** @format int32 */ + version: number; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -648,6 +717,16 @@ export interface UpdateEventSubmissionSettings { enabled?: null | boolean; } +export interface UpdateProductTourProgress { + status?: null | ProductTourStatus; + /** + * @format int32 + * @min 1 + * @max 2147483647 + */ + version: number; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateProject { name: string; @@ -732,6 +811,7 @@ export interface User { password_reset_token_expiration: string; o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; + product_tours: Record; /** Gets or sets the users Full Name. */ full_name: string; /** @format email */ @@ -771,6 +851,8 @@ export interface ViewCurrentUser { has_local_account: boolean; o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; + product_tours: Record; + product_tour_versions: Record; /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; organization_ids: string[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index f7e0970212..1d64508108 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,6 +27,20 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const ProductTourTelemetryEventSchema = zodEnum([ + "completed", + "dismissed", + "shown", + "started", +]); +export const ProductTourStatusSchema = zodEnum(["completed", "dismissed"]); +export const ProductTourLaunchSourceSchema = zodEnum([ + "automatic", + "catalog", + "command-palette", + "feature-announcement", + "help-menu", +]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -75,6 +89,44 @@ export type AdminAssistantUsageResponseFormData = Infer< typeof AdminAssistantUsageResponseSchema >; +export const AdminProductTourActivitySchema = object({ + date_utc: iso.datetime(), + event: ProductTourTelemetryEventSchema, + launch_source: ProductTourLaunchSourceSchema, + tour_name: string().min(1, "Tour name is required"), + user_identity: string().min(1, "User identity is required").nullable(), + user_name: string().min(1, "User name is required").nullable(), + version: int32(), + count: int(), +}); +export type AdminProductTourActivityFormData = Infer< + typeof AdminProductTourActivitySchema +>; + +export const AdminProductTourSummarySchema = object({ + name: string().min(1, "Name is required"), + shown: int(), + started: int(), + completed: int(), + dismissed: int(), + unique_users: int(), + last_run_utc: iso.datetime().nullable(), + completion_rate: number().nullable(), + dismissal_rate: number().nullable(), +}); +export type AdminProductTourSummaryFormData = Infer< + typeof AdminProductTourSummarySchema +>; + +export const AdminProductTourUsageResponseSchema = object({ + month: iso.datetime(), + tours: array(lazy(() => AdminProductTourSummarySchema)), + recent_activity: array(lazy(() => AdminProductTourActivitySchema)), +}); +export type AdminProductTourUsageResponseFormData = Infer< + typeof AdminProductTourUsageResponseSchema +>; + export const AssistantAccessResponseSchema = object({ enabled: boolean(), has_access: boolean(), @@ -626,6 +678,15 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const ProductTourProgressSchema = object({ + status: ProductTourStatusSchema, + updated_utc: iso.datetime(), + version: int32(), +}); +export type ProductTourProgressFormData = Infer< + typeof ProductTourProgressSchema +>; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -768,6 +829,16 @@ export type UpdateEventSubmissionSettingsFormData = Infer< typeof UpdateEventSubmissionSettingsSchema >; +export const UpdateProductTourProgressSchema = object({ + status: ProductTourStatusSchema, + version: int32() + .min(1, "Version must be at least 1") + .max(2147483647, "Version must be at most 2147483647"), +}); +export type UpdateProductTourProgressFormData = Infer< + typeof UpdateProductTourProgressSchema +>; + export const UpdateProjectSchema = object({ name: string().min(1, "Name is required").optional(), delete_bot_data_enabled: boolean().optional(), @@ -854,6 +925,10 @@ export const UserSchema = object({ password_reset_token_expiration: iso.datetime(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -899,6 +974,11 @@ export const ViewCurrentUserSchema = object({ has_local_account: boolean(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), + product_tour_versions: record(string(), number()).optional(), id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index 039baf20f5..6084aeef2c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -27,7 +27,7 @@
- + {#if isMediumScreenQuery.current} @@ -41,6 +41,7 @@