diff --git a/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs b/src/Exceptionless.Core/Models/Data/ProductTourProgress.cs new file mode 100644 index 0000000000..285da7c425 --- /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 int Version { get; set; } + public ProductTourStatus Status { get; set; } + public DateTime UpdatedUtc { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProductTourStatus +{ + [JsonStringEnumMemberName("dismissed")] + [EnumMember(Value = "dismissed")] + Dismissed, + [JsonStringEnumMemberName("completed")] + [EnumMember(Value = "completed")] + Completed +} diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 9e622dce0f..cfa2b6e494 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; @@ -23,6 +24,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public string? PasswordResetToken { get; set; } public DateTime PasswordResetTokenExpiration { get; set; } public ICollection OAuthAccounts { 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.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44ce3acd29..3928b0d721 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -37,6 +37,25 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder } }); + group.MapPut("users/me/product-tours/{tourId:minlength(1):maxlength(64)}", async (string tourId, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProductTourProgress? progress) + => progress is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new UserMessages.UpdateCurrentUserProductTour(tourId, progress))).ToHttpResult(resultMapper)) + .Accepts(false, "application/json", "application/*+json") + .Produces() + .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() { + ["tourId"] = "The stable product tour identifier.", + }, + ResponseDescriptions = new() { + ["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/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 92e9bd563e..b374375d30 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,10 @@ using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using Foundatio.Caching; +using Foundatio.Lock; using Foundatio.Repositories; using Foundatio.Mediator; +using System.Text.RegularExpressions; namespace Exceptionless.Web.Api.Handlers; @@ -26,6 +29,7 @@ public class UserHandler( IOAuthTokenRepository oauthTokenRepository, IOAuthApplicationRepository oauthApplicationRepository, ICacheClient cacheClient, + ILockProvider lockProvider, IMailer mailer, ApiMapper mapper, IntercomOptions intercomOptions, @@ -33,6 +37,8 @@ public class UserHandler( IHttpContextAccessor httpContextAccessor, ILoggerFactory loggerFactory) { + private const int MaximumProductTours = 32; + private static readonly Regex ProductTourIdRegex = new("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.CultureInvariant); private readonly ICacheClient _cache = new ScopedCacheClient(cacheClient, "User"); private readonly ILogger _logger = loggerFactory.CreateLogger(); private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable."); @@ -49,6 +55,54 @@ public async Task> Handle(GetCurrentUser message) }; } + public async Task> Handle(UpdateCurrentUserProductTour message) + { + if (!ProductTourIdRegex.IsMatch(message.TourId)) + return Result.Invalid(ValidationError.Create("tour_id", "Tour id can only contain lowercase letters, numbers, and single dashes.")); + + string currentUserId = GetCurrentUserId(); + Result? result = null; + bool lockAcquired = await lockProvider.TryUsingAsync($"product-tours:{currentUserId}", async () => + { + result = await UpdateCurrentUserProductTourAsync(currentUserId, message); + }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)); + + return lockAcquired && result is not null ? result : Result.Error("Unable to update product tour progress."); + } + + private async Task> UpdateCurrentUserProductTourAsync(string currentUserId, UpdateCurrentUserProductTour message) + { + var currentUser = await GetModelAsync(currentUserId, useCache: false); + if (currentUser is null) + return Result.NotFound("User not found."); + + bool isNewTour = !currentUser.ProductTours.TryGetValue(message.TourId, out var existingProgress); + if (isNewTour && currentUser.ProductTours.Count >= MaximumProductTours) + return Result.Invalid(ValidationError.Create("tour_id", $"A user cannot track more than {MaximumProductTours} product tours.")); + + var requestedStatus = message.Progress.Status!.Value; + if (existingProgress is null + || message.Progress.Version > existingProgress.Version + || (message.Progress.Version == existingProgress.Version + && existingProgress.Status == ProductTourStatus.Dismissed + && requestedStatus == ProductTourStatus.Completed)) + { + currentUser.ProductTours[message.TourId] = new ProductTourProgress + { + Version = message.Progress.Version, + Status = requestedStatus, + UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime + }; + + await repository.SaveAsync(currentUser, o => o.Cache()); + } + + return new ViewCurrentUser(currentUser, intercomOptions) + { + AvatarUrl = GetUserAvatarUrl(currentUser.Id, currentUser.AvatarFileName) + }; + } + public async Task>> Handle(GetCurrentUserOAuthGrants message) { var tokens = new List(); diff --git a/src/Exceptionless.Web/Api/Messages/UserMessages.cs b/src/Exceptionless.Web/Api/Messages/UserMessages.cs index 7cc329710b..ca1435b293 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 TourId, 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 909b6f700d..cf5d84adbf 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -301,6 +301,15 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } + async updateProductTour(token: string, tourId: string, version: number, status: 'completed' | 'dismissed'): Promise { + const response = await this.request.put(this.url(`users/me/product-tours/${tourId}`), { + 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..3f2def61aa 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -85,6 +85,7 @@ 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); + await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed'); await page.addInitScript( ({ organizationId, token }) => { diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 27e53fd48d..0cee1689bc 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -219,6 +219,18 @@ export class ExceptionlessE2EJourney { await expect(setupHeading).toBeVisible({ timeout: 30_000 }); + const welcomeDialog = this.page.getByRole('dialog', { name: 'Welcome to the new Exceptionless UI' }); + await expect(welcomeDialog).toBeVisible({ timeout: 30_000 }); + const skippedResponse = this.page.waitForResponse( + (response) => response.request().method() === 'PUT' && response.url().includes('/api/v2/users/me/product-tours/welcome') + ); + await welcomeDialog.getByRole('button', { name: 'Skip' }).click(); + expect((await skippedResponse).ok()).toBe(true); + await expect(welcomeDialog).toBeHidden(); + await this.page.reload(); + await expect(setupHeading).toBeVisible(); + await expect(welcomeDialog).toBeHidden(); + await this.page.getByLabel('Organization Name', { exact: true }).fill(this.organizationName); await this.page.getByLabel('Project Name', { exact: true }).fill(this.projectName); await this.page.getByRole('button', { name: 'Continue' }).click(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts index 029251c335..30d70c3f77 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts @@ -363,13 +363,18 @@ function recordRequest(diagnostics: RuntimeDiagnostics, request: Request, organi } function recordRequestFailure(diagnostics: RuntimeDiagnostics, request: Request): void { - if (!new URL(request.url()).pathname.startsWith('/api/v2/')) { + const path = new URL(request.url()).pathname; + const error = request.failure()?.errorText ?? null; + const isDeliberateRemount = diagnostics.activeAction.endsWith('route remounts'); + const isCanceledProjectRead = request.method() === 'GET' && /^\/api\/v2\/projects\/[a-f0-9]{24}$/.test(path) && error === 'net::ERR_ABORTED'; + // Repeated page.goto calls intentionally tear down detail observers; Chromium reports their superseded project reads as aborted. + if (!path.startsWith('/api/v2/') || (isDeliberateRemount && isCanceledProjectRead)) { return; } diagnostics.requestFailures.push({ action: diagnostics.activeAction, - error: request.failure()?.errorText ?? null, + error, method: request.method(), url: request.url() }); 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..a6941be444 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -0,0 +1,285 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; + +test.use({ e2eUseGeneratedUser: true }); + +const seededUserTest = test.extend({ e2eUseGeneratedUser: false }); + +test('Explore the new UI is replayable and clears when the authenticated app unmounts', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); + + await startTourFromCommand(page, 'Explore the new UI'); + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('new-ui-overview-desktop.png') }); + await tour.getByRole('button', { name: 'Close' }).click(); + await expect(tour).toBeHidden(); + + await startTourFromCommand(page, 'Explore the new UI'); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + + const helpMenu = page.locator('[data-tour="help-menu"]'); + await helpMenu.focus(); + await helpMenu.press('Enter'); + const logOut = page.getByRole('menuitem', { exact: true, name: 'Log Out' }); + await expect(logOut).toBeVisible(); + await logOut.focus(); + await logOut.press('Enter'); + + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible({ timeout: 30_000 }); + await expect(tour).toBeHidden(); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem('exceptionless.product-tour'))).toBeNull(); +}); + +test('Explore the new UI opens its navigation target on mobile', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + await page.setViewportSize({ height: 844, width: 390 }); + await page.goto('/next/stack'); + + await startTourFromCommand(page, 'Explore the new UI'); + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Your workspace navigation')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('new-ui-overview-mobile.png') }); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Reuse configured views')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Help is always nearby')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Find anything quickly')).toBeVisible(); + await expect(page.locator('[data-tour="mobile-navigation-trigger"]')).toBeVisible(); + await tour.getByRole('button', { name: 'Close' }).click(); +}); + +test('Configure a project resumes through its first event', async ({ e2eApi, e2eScenario, page }, testInfo) => { + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); + + let createdProjectId: string | undefined; + await startTourFromCommand(page, 'Configure a project'); + if (await page.getByRole('alertdialog', { name: 'Create another project?' }).isVisible()) { + await page.getByRole('button', { name: 'Create Project' }).click(); + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + await page.getByLabel('Project Name', { exact: true }).fill(`Tour Project ${e2eScenario.run}`); + await expect(page.locator('.driver-popover').getByText('Name your project', { exact: true })).toBeVisible(); + await page.locator('.driver-popover').getByRole('button', { name: 'Next' }).click(); + await expect(page.locator('.driver-popover').getByText('Continue to configuration')).toBeVisible(); + await page.locator('.driver-popover').getByRole('button', { name: 'Continue' }).click(); + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + createdProjectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + } + + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Choose your SDK')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('configure-project-platform.png') }); + + await page.locator('[data-tour="project-configure-platform"]').click(); + await page.getByRole('option', { name: 'Browser applications' }).click(); + await page.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Use the project token')).toBeVisible(); + await page.getByRole('button', { name: 'Next' }).click(); + await expect(page.getByText('Connect your application', { exact: true })).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('configure-project-inline.png') }); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByText('Waiting for your first event')).toBeVisible(); + + try { + const projectId = createdProjectId ?? e2eScenario.projectId; + const projectToken = createdProjectId ? (await e2eApi.getProjectDefaultToken(e2eScenario.userToken, createdProjectId)).id : e2eScenario.projectToken; + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId, + projectToken, + referenceId: e2eScenario.referenceId + }); + await expect(page).toHaveURL(/\/next\/event/); + await expect(page.getByText('First event received. Opening Events...')).toBeHidden(); + } finally { + if (createdProjectId) { + await e2eApi.deleteProject(e2eScenario.userToken, createdProjectId); + await e2eApi.waitForProjectDeleted(e2eScenario.userToken, createdProjectId); + } + } +}); + +test('Create a saved view retains a private hydrated view', async ({ e2eScenario, page }, testInfo) => { + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/event'); + + await startTourFromCommand(page, 'Create a saved view'); + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Open View settings')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('saved-view-open.png') }); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(tour.getByText('Configure what the view remembers')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('saved-view-settings.png') }); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Create a new view')).toBeVisible(); + await tour.getByRole('button', { name: 'Continue' }).click(); + + const viewName = `Tour View ${e2eScenario.run}`; + await expect(page.getByText('Review and name your view')).toBeVisible(); + await page.getByLabel('Name', { exact: true }).fill(viewName); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByText('Keep it private')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('saved-view-private.png') }); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByText('Create the saved view')).toBeVisible(); + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + + await expect(page.getByRole('heading', { name: viewName })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/event\/[^/]+/); + await expect(page.getByText('Create the saved view')).toBeHidden(); +}); + +seededUserTest('Investigate an error resumes after navigation and advances only after an error opens', async ({ e2eApi, e2eScenario, page }, testInfo) => { + const event = await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/event?time=all'); + await expect(page.getByText(e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + await startTourFromCommand(page, 'Investigate an error'); + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Start with the right errors')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Open a real error')).toBeVisible(); + await expect(page).toHaveURL(/\/next\/event\?time=all&type=error/); + await tour.getByRole('button', { name: 'Close' }).click(); + + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Investigate an error'); + const navigationConfirmation = page.getByRole('alertdialog', { name: 'Open Errors?' }); + await expect(navigationConfirmation).toBeVisible(); + await navigationConfirmation.getByRole('button', { name: 'Open Errors' }).click(); + await expect(tour.getByText('Start with the right errors')).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); + await expect(tour.getByText('Open a real error')).toBeVisible(); + await expect(page).toHaveURL(/\/next\/event\?time=all&type=error/); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('investigate-error-list.png') }); + await page.locator('tr').filter({ hasText: e2eScenario.message }).first().click(); + const investigationCallout = page.locator('[data-product-tour-inline="investigate-error"]'); + await expect(investigationCallout.getByText('Understand the grouped issue')).toBeVisible({ timeout: 30_000 }); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('investigate-error-details.png') }); + + for (const title of [ + 'Triage deliberately', + 'Inspect the occurrence', + 'Begin with the overview', + 'Follow the exception', + 'Reconstruct the request', + 'Check where it happened', + 'Review custom context', + 'Compare every occurrence' + ]) { + await investigationCallout.getByRole('button', { name: 'Continue' }).click(); + await expect(investigationCallout.getByText(title)).toBeVisible(); + } + + await expect(page.locator('[data-tour="event-tab-extended-data"]')).toHaveAttribute('data-state', 'active'); + await investigationCallout.getByRole('button', { name: 'Finish guide' }).click(); + await expect(investigationCallout).toBeHidden(); + + await page.goto(`/next/event/${event.id}`); + await expect(page.locator('[data-tour="event-details"]')).toBeVisible({ timeout: 30_000 }); + await startTourFromCommand(page, 'Investigate an error'); + await expect(investigationCallout.getByText('Understand the grouped issue')).toBeVisible(); + await investigationCallout.getByRole('button', { name: 'Continue' }).click(); + await investigationCallout.getByRole('button', { name: 'End guide' }).click(); + expect(event.type).toBe('error'); + + const usageReferenceId = `pw-e2e-tour-usage-${e2eScenario.run}`.slice(0, 100); + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, { + data: { e2e_reference: usageReferenceId }, + message: `Feature usage ${e2eScenario.run}`, + reference_id: usageReferenceId, + source: 'playwright-e2e', + type: 'usage' + }); + const usageEvent = await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, usageReferenceId); + await page.goto(`/next/event/${usageEvent.id}`); + await expect(page.locator('[data-tour="event-details"][data-event-type="usage"]')).toBeVisible({ timeout: 30_000 }); + await page.waitForURL(/\/next\/stack\/[^/]+\/event\/[^/]+/); + + const usageUrl = page.url(); + await startTourFromCommand(page, 'Investigate an error'); + const nonErrorConfirmation = page.getByRole('alertdialog', { name: 'Open Errors?' }); + await expect(nonErrorConfirmation).toBeVisible(); + await nonErrorConfirmation.getByRole('button', { name: 'Cancel' }).click(); + await expect(nonErrorConfirmation).toBeHidden(); + expect(page.url()).toBe(usageUrl); + await expect(investigationCallout).toBeHidden(); +}); + +test('Exie announcement can be dismissed without hiding the replayable guide', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + await page.route('**/api/v2/assistant/access**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: { enabled: true, has_access: true, message: null, upgrade_required: false } + }); + }); + + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); + + const announcement = page.locator('[data-product-tour-announcement="exie"]'); + await expect(announcement).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-announcement.png') }); + const dismissed = page.waitForResponse( + (response) => response.url().includes('/product-tours/exie-announcement') && response.request().method() === 'PUT' && response.status() === 200 + ); + await announcement.getByRole('button', { exact: true, name: 'Dismiss' }).click(); + await dismissed; + await page.reload(); + await expect(announcement).toBeHidden(); + + await startTourFromCommand(page, 'Meet Exie'); + await expect(page.locator('.driver-popover').getByText('Open Exie', { exact: true })).toBeVisible(); +}); + +test.describe('with the seeded user', () => { + test.use({ e2eUseGeneratedUser: false }); + + test('Meet Exie opens contextual UI without sending a provider request', async ({ e2eScenario: _e2eScenario, page }, testInfo) => { + void _e2eScenario; + let chatRequests = 0; + await page.route('**/api/v2/assistant/access**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: { enabled: true, has_access: true, message: null, upgrade_required: false } + }); + }); + await page.route('**/api/v2/assistant/chat**', async (route) => { + chatRequests += 1; + await route.abort(); + }); + + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Meet Exie'); + + const tour = page.locator('.driver-popover'); + await expect(tour.getByText('Open Exie', { exact: true })).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-trigger.png') }); + await tour.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('dialog', { name: 'Exie' })).toBeVisible(); + await expect(tour.getByText('You control every request')).toBeVisible(); + await page.screenshot({ fullPage: true, path: testInfo.outputPath('meet-exie-panel.png') }); + expect(chatRequests).toBe(0); + + await tour.getByRole('button', { name: 'Next' }).click(); + expect(chatRequests).toBe(0); + }); +}); + +async function startTourFromCommand(page: import('@playwright/test').Page, title: string): Promise { + 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 9333168e47..1305b26f61 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.13", + "driver.js": "^1.8.0", "layerchart": "^2.1.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", @@ -6047,6 +6048,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 87c7e0ec44..2b815760cc 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.13", + "driver.js": "^1.8.0", "layerchart": "^2.1.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", 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 @@ step.id === productTourHost.activeStepId) : undefined + ); + const activeInvestigationTab = $derived.by(() => { + switch (activeInvestigationStep?.id) { + case 'tab-environment': + return 'Environment'; + case 'tab-exception': + return 'Exception'; + case 'tab-extended-data': + return 'Extended Data'; + case 'tab-overview': + return 'Overview'; + case 'tab-request': + return 'Request'; + case 'tab-session': + return 'Session Events'; + case 'tab-trace': + return 'Trace Log'; + default: + return undefined; + } + }); + const isStackInvestigationStep = $derived(activeInvestigationStep?.id === 'stack-summary' || activeInvestigationStep?.id === 'stack-triage'); + const isEventInvestigationStep = $derived(activeInvestigationStep?.id === 'event-occurrence' || activeInvestigationStep?.id === 'filter-stack-events'); + const isTabInvestigationStep = $derived(activeInvestigationStep?.id.startsWith('tab-') ?? false); + + function getTabTourAnchor(tab: TabType): string | undefined { + switch (tab) { + case 'Environment': + return PRODUCT_TOUR_ANCHORS.eventTabEnvironment; + case 'Exception': + return PRODUCT_TOUR_ANCHORS.eventTabException; + case 'Extended Data': + return PRODUCT_TOUR_ANCHORS.eventTabExtendedData; + case 'Overview': + return PRODUCT_TOUR_ANCHORS.eventTabOverview; + case 'Request': + return PRODUCT_TOUR_ANCHORS.eventTabRequest; + case 'Session Events': + return PRODUCT_TOUR_ANCHORS.eventTabSession; + case 'Trace Log': + return PRODUCT_TOUR_ANCHORS.eventTabTrace; + default: + return undefined; + } + } + function isPromotedTab(tab: TabType): boolean { return !!projectQuery.data?.promoted_tabs?.includes(tab); } @@ -275,6 +327,23 @@ } } + function continueInvestigationTour(): void { + if (!activeInvestigationStep) { + return; + } + + if (activeInvestigationStep.id === 'filter-stack-events') { + void productTourHost.complete('investigate-error'); + return; + } + + productTourHost.advance('investigate-error', activeInvestigationStep.id); + } + + function dismissInvestigationTour(): void { + productTourHost.dismiss('investigate-error'); + } + function prepareEventAssistantContext(): void { if (event) { assistantPageContext.setPageEvent(event); @@ -294,6 +363,8 @@ $effect(() => { if (event && event.id !== notifiedEventId) { notifiedEventId = event.id; + const eventType = hasErrorOrSimpleError(event) ? 'error' : event.type; + void tick().then(() => productTourHost.eventOpened(eventType)); onEventLoaded?.(event); } }); @@ -307,6 +378,29 @@ }); }); + $effect(() => { + const step = activeInvestigationStep; + const tab = activeInvestigationTab; + if (!event || !step) { + return; + } + + if (tab && tabs.includes(tab)) { + activeTab = tab; + } + + void tick().then(() => { + if (productTourHost.activeStepId !== step.id || !step.anchor) { + return; + } + + document.querySelector(productTourSelector(step.anchor))?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }); + }); + onMount(() => { updateTabsOverflow(); @@ -324,19 +418,45 @@ }); -
+{#if event && isStackInvestigationStep && activeInvestigationStep} + +{/if} + +

Stack

{#if event?.stack_id} - +
+ +
{/if}
-
+{#if event && isEventInvestigationStep && activeInvestigationStep} + +{/if} + +

Event

@@ -348,6 +468,7 @@ {#if event?.stack_id} +
+ {/each} + + + 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-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..bbd88fcc80 --- /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-welcome.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte new file mode 100644 index 0000000000..837da4fd8d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte @@ -0,0 +1,48 @@ + + + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + showCloseButton={false} + > + +
+
+ Welcome to the new Exceptionless UI + 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/product-tour-welcome.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts new file mode 100644 index 0000000000..c87d990aab --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-welcome.svelte.test.ts @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import ProductTourWelcome from './product-tour-welcome.svelte'; + +const recommended = { + availability: { available: true }, + description: 'Learn navigation and search.', + getAvailability: vi.fn(), + getSteps: vi.fn(), + id: 'new-ui-overview' as const, + keywords: ['navigation'], + title: 'Explore the new UI', + version: 1 +}; + +describe('ProductTourWelcome', () => { + it('records only explicit chooser actions', async () => { + const onBrowse = vi.fn(); + const onSkip = vi.fn(); + const onStart = vi.fn(); + render(ProductTourWelcome, { onBrowse, onSkip, onStart, open: true, recommended }); + + await fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onBrowse).not.toHaveBeenCalled(); + expect(onSkip).not.toHaveBeenCalled(); + expect(onStart).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole('button', { name: 'Explore the new UI' })); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it('provides Browse Guides and Skip choices', async () => { + const onBrowse = vi.fn(); + const onSkip = vi.fn(); + render(ProductTourWelcome, { onBrowse, onSkip, 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(onSkip).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte new file mode 100644 index 0000000000..a715911ce2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tours.svelte @@ -0,0 +1,907 @@ + + + void onWelcomeSkip()} + onStart={() => void onWelcomeStart()} + {recommended} +/> + +{#if exieAnnouncementOpen && assistantAccess} + void onExieAnnouncementDismiss()} + onStart={() => void onExieAnnouncementStart()} + /> +{/if} + + void startTour(id, catalogSource)} /> + +{#if pendingConfirmation} + { + if (!open) { + pendingConfirmation = undefined; + } + }} + > + + + {pendingConfirmation.action.title} + {pendingConfirmation.action.description} + + + Cancel + void confirmNavigation()}>{pendingConfirmation.action.actionLabel} + + + +{/if} 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..eed9c6af2a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -0,0 +1,24 @@ +import { ProductTourStatus } from '$generated/api'; +import { describe, expect, it } from 'vitest'; + +import { shouldOfferProductTourAnnouncement, shouldOfferProductTourWelcome } from './eligibility'; + +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..6967783729 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -0,0 +1,9 @@ +import type { ProductTourProgress } from '$features/users/models'; + +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/product-tours.css b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css new file mode 100644 index 0000000000..dd0469df68 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/product-tours.css @@ -0,0 +1,44 @@ +.driver-popover.product-tour-popover { + background: var(--popover); + color: var(--popover-foreground); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 10px 25px color-mix(in srgb, var(--foreground) 12%, transparent); + font-family: var(--font-sans); + max-width: 22rem; +} + +.product-tour-popover .driver-popover-title { + font-size: 0.95rem; + font-weight: 600; +} + +.product-tour-popover .driver-popover-description, +.product-tour-popover .driver-popover-progress-text { + color: var(--muted-foreground); +} + +.product-tour-popover .driver-popover-footer button { + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + box-shadow: none; + text-shadow: none; +} + +/* Driver disables pointer events outside the active spotlight. Select menus are portaled + to the document body, so keep an open menu interactive while a tour is running. */ +.driver-active [data-slot='select-content'], +.driver-active [data-slot='select-content'] * { + pointer-events: auto; +} + +.driver-active [data-slot='select-content'] { + z-index: 10001 !important; +} + +@media (prefers-reduced-motion: reduce) { + .driver-overlay, + .driver-popover { + transition: none !important; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts new file mode 100644 index 0000000000..53221790d7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clearProductTourSession, readProductTourSession, writeProductTourSession } from './session'; + +describe('product tour session', () => { + it('round-trips stable resume state', () => { + let value: null | string = null; + const storage = { + getItem: () => value, + removeItem: vi.fn(() => (value = null)), + setItem: vi.fn((_key: string, next: string) => (value = next)) + }; + const session = { source: 'command-palette', stepId: 'choose-error', tourId: 'investigate-error', version: 1 } as const; + + writeProductTourSession(session, storage); + expect(readProductTourSession(storage)).toEqual(session); + clearProductTourSession(storage); + expect(value).toBeNull(); + }); + + it('clears malformed state instead of blocking future guides', () => { + const removeItem = vi.fn(); + const storage = { getItem: () => '{not-json', removeItem }; + + expect(readProductTourSession(storage)).toBeUndefined(); + expect(removeItem).toHaveBeenCalledOnce(); + }); +}); 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..7f618048b8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/session.ts @@ -0,0 +1,28 @@ +import type { ProductTourId, ProductTourLaunchSource } from './types'; + +export interface StoredProductTourSession { + source: ProductTourLaunchSource; + stepId?: string; + tourId: ProductTourId; + version: number; +} + +const SESSION_KEY = 'exceptionless.product-tour'; + +export function clearProductTourSession(storage: Pick = sessionStorage): void { + storage.removeItem(SESSION_KEY); +} + +export function readProductTourSession(storage: Pick = sessionStorage): StoredProductTourSession | undefined { + try { + const value = storage.getItem(SESSION_KEY); + return value ? (JSON.parse(value) as StoredProductTourSession) : undefined; + } catch { + clearProductTourSession(storage); + return undefined; + } +} + +export function writeProductTourSession(session: StoredProductTourSession, storage: Pick = sessionStorage): void { + storage.setItem(SESSION_KEY, JSON.stringify(session)); +} 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..817f11a1b8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { productTourHost } from './state.svelte'; + +describe('product tour host', () => { + it('waits for completion listeners before resolving', async () => { + let finishPersistence: () => void = () => undefined; + const persistence = new Promise((resolve) => (finishPersistence = resolve)); + let persisted = false; + const unsubscribe = productTourHost.subscribe(async () => { + await persistence; + persisted = true; + }); + + try { + const completion = productTourHost.complete('configure-project'); + await Promise.resolve(); + expect(persisted).toBe(false); + + finishPersistence(); + expect(await completion).toBe(true); + expect(persisted).toBe(true); + } finally { + unsubscribe(); + } + }); + + it('propagates completion listener failures', async () => { + const unsubscribe = productTourHost.subscribe(() => false); + + try { + await expect(productTourHost.complete('configure-project')).resolves.toBe(false); + } finally { + unsubscribe(); + } + }); +}); 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..8d36962e3d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -0,0 +1,100 @@ +import type { ProductTourId, ProductTourLaunchSource } from './types'; + +export type ProductTourHostEvent = + | { eventType?: string; type: 'event-opened' } + | { stepId: string; tourId: ProductTourId; type: 'advance' } + | { tourId: ProductTourId; type: 'completed' | 'dismissed' }; + +type ProductTourHostListener = (event: ProductTourHostEvent) => boolean | Promise | void; + +class ProductTourHost { + get activeStepId(): string | undefined { + return this.session?.stepId; + } + get activeTourId(): ProductTourId | undefined { + return this.session?.tourId; + } + + get organizationId(): string | undefined { + return this.session?.organizationId; + } + + get source(): ProductTourLaunchSource | undefined { + return this.session?.source; + } + + private readonly listeners = new Set(); + + private session = $state<{ + organizationId?: string; + source: ProductTourLaunchSource; + stepId?: string; + tourId: ProductTourId; + }>(); + + advance(tourId: ProductTourId, stepId: string): void { + this.publish({ + stepId, + tourId, + type: 'advance' + }); + } + + clear(): void { + this.session = undefined; + } + + async complete(tourId: ProductTourId): Promise { + const event: ProductTourHostEvent = { + tourId, + type: 'completed' + }; + const results = await Promise.all([...this.listeners].map((listener) => listener(event))); + return results.every((result) => result !== false); + } + + dismiss(tourId: ProductTourId): void { + this.publish({ + tourId, + type: 'dismissed' + }); + } + + eventOpened(eventType?: string): void { + this.publish({ + eventType, + type: 'event-opened' + }); + } + + isActive(tourId: ProductTourId): boolean { + return this.activeTourId === tourId; + } + + set(tourId: ProductTourId, stepId: string | undefined, source?: ProductTourLaunchSource, organizationId?: string): void { + const activeSource = source ?? this.session?.source; + if (!activeSource) { + throw new Error('A product tour session requires a launch source.'); + } + + this.session = { + organizationId: organizationId ?? this.session?.organizationId, + source: activeSource, + stepId, + tourId + }; + } + + subscribe(listener: ProductTourHostListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private publish(event: ProductTourHostEvent): void { + for (const listener of this.listeners) { + void listener(event); + } + } +} + +export const productTourHost = new ProductTourHost(); 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..3021de3d1c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { buildProductTourTelemetryEvent } from './telemetry'; + +describe('product tour telemetry', () => { + it('uses stable catalog metadata only', () => { + expect(buildProductTourTelemetryEvent('step', 'new-ui-overview', 1, 'command-palette', 'command-search')).toBe( + 'product-tour.step.new-ui-overview.v1.command-palette.command-search' + ); + expect(buildProductTourTelemetryEvent('announcement-started', 'exie-announcement', 1, 'feature-announcement')).toBe( + 'product-tour.announcement-started.exie-announcement.v1.feature-announcement' + ); + }); + + it('rejects resource data and invalid versions', () => { + expect(() => buildProductTourTelemetryEvent('step', 'new-ui-overview', 1, 'catalog', 'Customer Project' as never)).toThrow(); + 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..bda21db74a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/telemetry.ts @@ -0,0 +1,34 @@ +import type { ProductTourKey, ProductTourLaunchSource } from './types'; + +export type ProductTourTelemetryEvent = + | 'announcement-dismissed' + | 'announcement-shown' + | 'announcement-started' + | 'chooser-shown' + | 'chooser-skipped' + | 'chooser-started' + | 'completed' + | 'dismissed' + | 'failed' + | 'started' + | 'step'; + +const SAFE_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function buildProductTourTelemetryEvent( + event: ProductTourTelemetryEvent, + id: ProductTourKey, + version: number, + source: ProductTourLaunchSource, + stepId?: string +): string { + if (!SAFE_SEGMENT.test(event) || !SAFE_SEGMENT.test(id) || !SAFE_SEGMENT.test(source) || (stepId && !SAFE_SEGMENT.test(stepId))) { + throw new Error('Product tour telemetry accepts stable catalog identifiers only.'); + } + + if (!Number.isSafeInteger(version) || version < 1) { + throw new Error('Product tour telemetry requires a positive version.'); + } + + return ['product-tour', event, id, `v${version}`, source, stepId].filter(Boolean).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..4c42586ee4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/types.ts @@ -0,0 +1,57 @@ +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewProject } from '$features/projects/models'; +import type { ProductTourProgress } from '$features/users/models'; + +export interface ProductTourAvailability { + available: boolean; + reason?: string; +} +export interface ProductTourContext { + assistantAccess?: AssistantAccess; + errorEventAvailability: ProductTourErrorEventAvailability; + isSetupPage: boolean; + openEventType?: string; + organizationId?: string; + pathname: string; + projects: ViewProject[]; +} +export interface ProductTourDefinition { + description: string; + getAvailability: (context: ProductTourContext) => ProductTourAvailability; + getStartAction?: (context: ProductTourContext) => ProductTourStartAction; + getSteps: (context: ProductTourContext) => ProductTourStep[]; + id: ProductTourId; + keywords: readonly string[]; + title: string; + version: number; +} +export type ProductTourErrorEventAvailability = 'available' | 'empty' | 'error' | 'loading'; +export type ProductTourId = 'configure-project' | 'create-saved-view' | 'investigate-error' | 'meet-exie' | 'new-ui-overview'; +export type ProductTourKey = 'exie-announcement' | 'welcome' | ProductTourId; + +export type ProductTourLaunchSource = 'automatic' | 'catalog' | 'command-palette' | 'feature-announcement' | 'help-menu'; + +export interface ProductTourListItem extends ProductTourDefinition { + availability: ProductTourAvailability; + progress?: ProductTourProgress; +} + +export type ProductTourPresentation = 'inline' | 'spotlight'; + +export type ProductTourStartAction = + | { actionLabel: string; description: string; destination: string; title: string; type: 'confirm-navigation' } + | { destination: string; type: 'navigate' } + | { stepId?: string; type: 'launch' }; + +export interface ProductTourStep { + advanceOnClick?: boolean; + anchor?: string; + description: string; + id: string; + optional?: boolean; + presentation?: ProductTourPresentation; + resumeStepId?: string; + showDone?: boolean; + title: string; + waitForElement?: number; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts index 7dd981b258..be01c26312 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -135,7 +135,11 @@ export function getSavedViewsByViewQuery(request: { route: { organizationId: str enabled: () => !!accessToken.current && !!request.route.organizationId && !!request.route.view, queryFn: async () => { const client = useFetchClient(); - const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views/${request.route.view}`); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views/${request.route.view}`, { + params: { + limit: 1000 + } + }); return response.data!; }, queryKey: queryKeys.view(request.route.organizationId, request.route.view), @@ -148,7 +152,11 @@ export function getSavedViewsQuery(request: { route: { organizationId: string | enabled: () => !!accessToken.current && !!request.route.organizationId, queryFn: async () => { const client = useFetchClient(); - const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views`); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views`, { + params: { + limit: 1000 + } + }); return response.data!; }, queryKey: queryKeys.organization(request.route.organizationId), 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..3fe0029d30 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 @@ -5,6 +5,8 @@ import { Input } from '$comp/ui/input'; import { Label } from '$comp/ui/label'; import { Switch } from '$comp/ui/switch'; + import ProductTourInlineCallout from '$features/product-tours/components/product-tour-inline-callout.svelte'; + import { productTourHost } from '$features/product-tours/state.svelte'; import type { SavedView } from '../models'; @@ -19,16 +21,30 @@ } from '../slugs'; interface Props { + defaultPrivate?: boolean; duplicateView?: SavedView; + onCancel?: () => void; onClose: () => void; - onLoadView: (view: SavedView) => void; + onLoadView: (view: SavedView) => Promise | void; onSave: (name: string, slug: string, isPrivate: boolean) => Promise; open: boolean; + pendingCompletion?: boolean; savedViews: SavedView[]; saving: boolean; } - let { duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); + let { + defaultPrivate = false, + duplicateView, + onCancel, + onClose, + onLoadView, + onSave, + open = $bindable(), + pendingCompletion = false, + savedViews, + saving + }: Props = $props(); let saveName = $state(''); let saveSlug = $state(''); @@ -80,14 +96,14 @@ }); const visibleNameError = $derived(attemptedSubmit || saveName.length > 0 ? nameError : undefined); const visibleSlugError = $derived(attemptedSubmit || saveName.length > 0 || saveSlug.length > 0 ? slugError : undefined); - const canSave = $derived(!nameError && !slugError && !saving); + const canSave = $derived((pendingCompletion || (!nameError && !slugError)) && !saving); $effect(() => { if (open) { saveName = ''; saveSlug = ''; isSlugDirty = false; - isPrivate = false; + isPrivate = defaultPrivate; attemptedSubmit = false; } }); @@ -113,24 +129,71 @@ await onSave(trimmedName, normalizedSlug, isPrivate); } + + function dismissTour(): void { + onClose(); + onCancel?.(); + } - - + { + if (nextOpen || !saving) { + open = nextOpen; + if (!nextOpen) { + onCancel?.(); + } + } + }} +> + saving && event.preventDefault()} + onInteractOutside={(event) => saving && event.preventDefault()} + > Save View Save the current view configuration for quick access. - {#if duplicateView} + {#if defaultPrivate && productTourHost.activeStepId === 'name-view'} + productTourHost.advance('create-saved-view', 'name-view')} + onDismiss={dismissTour} + title="Review and name your view" + tourId="create-saved-view" + /> + {:else if defaultPrivate && productTourHost.activeStepId === 'private-view'} + productTourHost.advance('create-saved-view', 'private-view')} + onDismiss={dismissTour} + title="Keep it private" + tourId="create-saved-view" + /> + {:else if defaultPrivate && productTourHost.activeStepId === 'save-view'} + + {/if} + {#if duplicateView && !pendingCompletion}
Current filters match "{duplicateView.name}". You can instead, or save with a different name. @@ -146,6 +209,7 @@
{#if visibleNameError}

{visibleNameError}

@@ -169,6 +234,7 @@ aria-invalid={!!visibleSlugError} aria-describedby={visibleSlugError ? 'view-slug-error' : undefined} required + disabled={pendingCompletion} oninput={() => { isSlugDirty = true; }} @@ -177,17 +243,24 @@

{visibleSlugError}

{/if}
-
+
- Only visible to you + {defaultPrivate ? 'Required for this guided practice view' : 'Only visible to you'}
- +
- - + 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 9287293041..da448df149 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 @@ -14,6 +14,7 @@ import { toFilter } from '$features/events/components/filters/helpers.svelte'; import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { organization } from '$features/organizations/context.svelte'; + import { productTourHost } from '$features/product-tours/state.svelte'; import Columns3 from '@lucide/svelte/icons/columns-3'; import Pencil from '@lucide/svelte/icons/pencil'; import Plus from '@lucide/svelte/icons/plus'; @@ -54,8 +55,9 @@ filters: IFilter[]; isModified: boolean; onClearSavedView: () => void; - onLoadView: (view: SavedView) => void; + onLoadView: (view: SavedView) => Promise | void; onResetToSaved: () => void; + onSavedViewCreated?: (view: SavedView) => Promise | void; savedViews: SavedView[]; setAutoFillColumnId: (columnId: AutoFillColumnSelection) => void; setShowChart?: (show: boolean) => void; @@ -80,6 +82,7 @@ onClearSavedView, onLoadView, onResetToSaved, + onSavedViewCreated, savedViews, setAutoFillColumnId, setShowChart, @@ -97,6 +100,8 @@ let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); + let isCompletingTour = $state(false); + let pendingCreatedView = $state(); let viewToDelete = $state(null); const organizationId = $derived(organization.current); @@ -123,7 +128,7 @@ } }); - const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending); + const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending || isCompletingTour); const currentFilterString = $derived(toFilter(filters.filter((f) => f.type !== 'date'))); // Auto-detect if current filters match an existing saved view for "load existing" hint @@ -195,7 +200,7 @@ columns: getSavedColumnSettings(), filter: currentFilterString || undefined, filter_definitions: filterDefinitions, - is_private: isPrivate || undefined, + is_private: productTourHost.isActive('create-saved-view') || isPrivate ? true : undefined, name, organization_id: organizationId, show_chart: showChart, @@ -207,11 +212,25 @@ }; try { - const result = await createMutation.mutateAsync(body); + const result = pendingCreatedView ?? (await createMutation.mutateAsync(body)); + if (!pendingCreatedView) { + await onSavedViewCreated?.(result); + } + + isCompletingTour = true; + const completed = await productTourHost.complete('create-saved-view'); + isCompletingTour = false; + if (!completed) { + pendingCreatedView = result; + return; + } + + pendingCreatedView = undefined; isSaveDialogOpen = false; - onLoadView(result); + await onLoadView(result); toast.success(`Saved view "${result.name}" created.`); } catch (error) { + isCompletingTour = false; toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); } } @@ -291,7 +310,7 @@ {#snippet child({ props })} - {/snippet} - + Saved View {#if activeView} @@ -309,7 +328,7 @@ Save {/if} - + @@ -377,8 +396,16 @@ {duplicateView} {savedViews} {saving} + defaultPrivate={productTourHost.isActive('create-saved-view')} + pendingCompletion={!!pendingCreatedView} onSave={handleSave} onClose={() => (isSaveDialogOpen = false)} + onCancel={() => { + pendingCreatedView = undefined; + if (productTourHost.isActive('create-saved-view')) { + productTourHost.dismiss('create-saved-view'); + } + }} {onLoadView} /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 59627e249f..22162b905d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -345,9 +345,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 @@ > - - - - +
+ + + + +
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 e1b2055359..553ac1fc35 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,7 @@ 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, UpdateEmailAddressResult, UpdateProductTourProgress, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; export async function invalidateUserQueries(queryClient: QueryClient, message: WebSocketMessageValue<'UserChanged'>) { const { id } = message; @@ -41,6 +41,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: () => [...queryKeys.me(), 'product-tour'] as const, type: ['User'] as const }; @@ -260,6 +261,28 @@ export function postEmailAddress(request: PostEmailAddressRequest) { })); } +export function putCurrentUserProductTour() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ progress, tourId }) => { + const client = useFetchClient(); + const response = await client.putJSON(`users/me/product-tours/${tourId}`, progress); + return response.data!; + }, + mutationKey: queryKeys.productTour(), + onError: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.me() + }); + }, + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.me(), data); + queryClient.setQueryData(queryKeys.id(data.id), data); + } + })); +} + 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..3a41ba510e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts @@ -1,9 +1,20 @@ -export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; +import type { ProductTourProgress as GeneratedProductTourProgress, ViewCurrentUser as GeneratedViewCurrentUser } from '$generated/api'; + +export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewUser } from '$generated/api'; export interface InviteUserForm { email: string; } +export type ProductTourProgress = GeneratedProductTourProgress; + +export type ProductTourStatus = 'completed' | 'dismissed'; + +export interface UpdateProductTourProgress { + status: ProductTourStatus; + version: number; +} + export interface UpdateUser { email_notifications_enabled?: boolean; full_name?: string; @@ -12,3 +23,5 @@ export interface UpdateUser { export interface UpdateUserEmailAddress { email_address: string; } + +export type ViewCurrentUser = GeneratedViewCurrentUser; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 2411510e46..f44891da73 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,6 +7,11 @@ export enum StackStatus { Discarded = "discarded", } +export enum ProductTourStatus { + Dismissed = "dismissed", + Completed = "completed", +} + export enum BillingStatus { Trialing = 0, Active = 1, @@ -15,6 +20,84 @@ export enum BillingStatus { Unpaid = 4, } +export interface AdminAssistantOrganizationUsage { + organization_id: string; + organization_name: string; + plan_id: string; + /** @format date-time */ + last_used_utc: string; + /** @format int64 */ + turns: number; + /** @format int64 */ + completed: number; + /** @format int64 */ + failed: number; + /** @format int64 */ + cancelled: number; + /** @format int64 */ + provider_requests: number; + /** @format int64 */ + tool_calls: number; + /** @format int64 */ + prompt_tokens: number; + /** @format int64 */ + completion_tokens: number; + /** @format double */ + cost_usd: number; + /** @format int64 */ + blocked_by_concurrency: number; + /** @format int64 */ + blocked_by_rate_limit: number; + /** @format int64 */ + blocked_by_token_limit: number; + /** @format int64 */ + blocked_by_cost_limit: number; + /** @format int64 */ + monthly_token_limit?: null | number; + /** @format double */ + monthly_cost_limit_usd?: null | number; + /** @format double */ + token_utilization?: null | number; + /** @format double */ + cost_utilization?: null | number; +} + +export interface AdminAssistantUsageResponse { + /** @format date-time */ + month: string; + /** @format int64 */ + active_organizations: number; + /** @format int64 */ + turns: number; + /** @format int64 */ + prompt_tokens: number; + /** @format int64 */ + completion_tokens: number; + /** @format double */ + cost_usd: number; + organizations: AdminAssistantOrganizationUsage[]; +} + +export interface AssistantAccessResponse { + enabled: boolean; + has_access: boolean; + upgrade_required: boolean; + message?: null | string; +} + +export interface AssistantChatMessage { + role: string; + content: string; +} + +export interface AssistantChatRequest { + messages: AssistantChatMessage[]; + organization_id?: null | string; + project_id?: null | string; + path?: null | string; + conversation_id?: null | string; +} + export interface BillingPlan { id: string; name: string; @@ -386,6 +469,14 @@ export interface ProblemDetails { instance?: null | string; } +export interface ProductTourProgress { + /** @format int32 */ + version: number; + status: ProductTourStatus; + /** @format date-time */ + updated_utc: string; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -526,6 +617,16 @@ export interface UpdateEvent { description?: null | string; } +export interface UpdateProductTourProgress { + /** + * @format int32 + * @min 1 + * @max 2147483647 + */ + version: number; + status: ProductTourStatus; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateProject { name: string; @@ -604,6 +705,7 @@ export interface User { /** @format date-time */ password_reset_token_expiration: string; o_auth_accounts: OAuthAccount[]; + product_tours: Record; /** Gets or sets the users Full Name. */ full_name: string; /** @format email */ @@ -635,6 +737,7 @@ export interface ViewCurrentUser { hash?: null | string; has_local_account: boolean; o_auth_accounts: OAuthAccount[]; + product_tours: 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 6be8a910b2..27672cd1f4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,6 +27,7 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const ProductTourStatusSchema = zodEnum(["dismissed", "completed"]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -35,6 +36,81 @@ export const BillingStatusSchema = union([ literal(4), ]); +export const AdminAssistantOrganizationUsageSchema = object({ + organization_id: string().min(1, "Organization id is required"), + organization_name: string().min(1, "Organization name is required"), + plan_id: string().min(1, "Plan id is required"), + last_used_utc: iso.datetime(), + turns: int(), + completed: int(), + failed: int(), + cancelled: int(), + provider_requests: int(), + tool_calls: int(), + prompt_tokens: int(), + completion_tokens: int(), + cost_usd: number(), + blocked_by_concurrency: int(), + blocked_by_rate_limit: int(), + blocked_by_token_limit: int(), + blocked_by_cost_limit: int(), + monthly_token_limit: int().nullable(), + monthly_cost_limit_usd: number().nullable(), + token_utilization: number().nullable(), + cost_utilization: number().nullable(), +}); +export type AdminAssistantOrganizationUsageFormData = Infer< + typeof AdminAssistantOrganizationUsageSchema +>; + +export const AdminAssistantUsageResponseSchema = object({ + month: iso.datetime(), + active_organizations: int(), + turns: int(), + prompt_tokens: int(), + completion_tokens: int(), + cost_usd: number(), + organizations: array(lazy(() => AdminAssistantOrganizationUsageSchema)), +}); +export type AdminAssistantUsageResponseFormData = Infer< + typeof AdminAssistantUsageResponseSchema +>; + +export const AssistantAccessResponseSchema = object({ + enabled: boolean(), + has_access: boolean(), + upgrade_required: boolean(), + message: string().min(1, "Message is required").nullable().optional(), +}); +export type AssistantAccessResponseFormData = Infer< + typeof AssistantAccessResponseSchema +>; + +export const AssistantChatMessageSchema = object({ + role: string().min(1, "Role is required"), + content: string().min(1, "Content is required"), +}); +export type AssistantChatMessageFormData = Infer< + typeof AssistantChatMessageSchema +>; + +export const AssistantChatRequestSchema = object({ + messages: array(lazy(() => AssistantChatMessageSchema)), + organization_id: string() + .min(1, "Organization id is required") + .nullable() + .optional(), + project_id: string().min(1, "Project id is required").nullable().optional(), + path: string().min(1, "Path is required").nullable().optional(), + conversation_id: string() + .min(1, "Conversation id is required") + .nullable() + .optional(), +}); +export type AssistantChatRequestFormData = Infer< + typeof AssistantChatRequestSchema +>; + export const BillingPlanSchema = object({ id: string().min(1, "Id is required"), name: string().min(1, "Name is required"), @@ -504,6 +580,15 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const ProductTourProgressSchema = object({ + version: int32(), + status: ProductTourStatusSchema, + updated_utc: iso.datetime(), +}); +export type ProductTourProgressFormData = Infer< + typeof ProductTourProgressSchema +>; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -620,6 +705,16 @@ export const UpdateEventSchema = object({ }); export type UpdateEventFormData = Infer; +export const UpdateProductTourProgressSchema = object({ + version: int32() + .min(1, "Version must be at least 1") + .max(2147483647, "Version must be at most 2147483647"), + status: ProductTourStatusSchema, +}); +export type UpdateProductTourProgressFormData = Infer< + typeof UpdateProductTourProgressSchema +>; + export const UpdateProjectSchema = object({ name: string().min(1, "Name is required").optional(), delete_bot_data_enabled: boolean().optional(), @@ -694,6 +789,10 @@ export const UserSchema = object({ .optional(), password_reset_token_expiration: iso.datetime(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -726,6 +825,10 @@ export const ViewCurrentUserSchema = object({ hash: string().min(1, "Hash is required").nullable().optional(), has_local_account: boolean(), o_auth_accounts: array(lazy(() => OAuthAccountSchema)), + product_tours: record( + string(), + lazy(() => ProductTourProgressSchema), + ), 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 e0dc023364..507a2ec701 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 @@