From 28d57de60dd1a0337ec5662aaff6e5c03adc0771 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Mon, 19 Oct 2026 05:02:15 +0200 Subject: [PATCH 1/3] Dashboard: sign-in, setup wizard and administration --- README.md | 2 +- apps/ZoneTree.Analytics.Dashboard/README.md | 43 +- .../server/Admin/AdminEndpoints.cs | 114 ++++ .../server/Auth/AuthEndpoints.cs | 111 ++++ .../server/Auth/DashboardAuthentication.cs | 71 +++ .../server/Auth/DashboardGates.cs | 77 +++ .../server/Auth/PasswordHasher.cs | 47 ++ .../server/Auth/SessionStore.cs | 155 ++++++ .../server/Auth/SignInThrottle.cs | 58 ++ .../server/Auth/UserStore.cs | 226 ++++++++ .../server/DashboardAnalyticsOptions.cs | 17 +- .../server/DashboardDevelopmentServer.cs | 22 +- .../server/Engine/EngineHost.cs | 204 +++++++ .../server/Engine/EngineMiddleware.cs | 107 ++++ .../server/Engine/SiteResolution.cs | 53 ++ .../server/Http/DashboardJson.cs | 64 +++ .../server/Program.cs | 201 +++---- .../server/SampleDataStore.cs | 18 +- .../server/SampleEngineLease.cs | 5 - .../server/Settings/DashboardSettings.cs | 415 ++++++++++++++ .../server/Settings/SettingsDocument.cs | 46 ++ .../server/Settings/SettingsMapper.cs | 285 ++++++++++ .../server/Settings/SettingsPresets.cs | 66 +++ .../server/Settings/SettingsSchema.cs | 142 +++++ .../server/Settings/SettingsService.cs | 152 ++++++ .../server/Setup/SetupBanner.cs | 52 ++ .../server/Setup/SetupEndpoints.cs | 94 ++++ .../server/Setup/SetupKey.cs | 34 ++ .../server/System/AuditLog.cs | 26 + .../server/System/SystemStore.cs | 102 ++++ .../ZoneTree.Analytics.Dashboard.csproj | 8 + .../server/appsettings.json | 12 +- .../ZoneTree.Analytics.Dashboard/ui/README.md | 49 +- .../ui/content/account/account.mdx | 12 + .../ui/content/account/account.ts | 95 ++++ .../account/settings/account-settings.ts | 104 ++++ .../ui/content/account/settings/settings.mdx | 12 + .../ui/content/admin/admin.mdx | 12 + .../ui/content/admin/admin.ts | 126 +++++ .../ui/content/admin/audit/audit.mdx | 12 + .../ui/content/admin/audit/audit.ts | 99 ++++ .../ui/content/admin/indexes/indexes.mdx | 12 + .../ui/content/admin/indexes/indexes.ts | 298 ++++++++++ .../ui/content/admin/settings/settings.mdx | 12 + .../ui/content/admin/settings/settings.ts | 146 +++++ .../ui/content/admin/sites/sites.mdx | 12 + .../ui/content/admin/sites/sites.ts | 102 ++++ .../ui/content/admin/users/users.mdx | 12 + .../ui/content/admin/users/users.ts | 297 ++++++++++ .../content/components/report-components.ts | 4 +- .../ui/content/components/settings-fields.ts | 106 ++++ .../ui/content/components/sites-editor.ts | 208 +++++++ .../ui/content/connection/connection.mdx | 12 - .../ui/content/connection/connection.ts | 235 -------- .../ui/content/header.mdx | 47 +- .../ui/content/lib/admin-contracts.ts | 143 +++++ .../ui/content/lib/admin.ts | 172 ++++++ .../ui/content/lib/api.ts | 7 +- .../ui/content/lib/config.ts | 40 +- .../ui/content/lib/contracts.ts | 9 + .../ui/content/lib/dashboard/requests.ts | 10 - .../ui/content/lib/page.ts | 125 +++++ .../ui/content/lib/reports.ts | 4 - .../ui/content/lib/session.ts | 147 +++++ .../ui/content/lib/settings-model.ts | 330 ++++++++++++ .../ui/content/sample-data/sample-data.ts | 3 +- .../ui/content/setup/setup.mdx | 11 + .../ui/content/setup/setup.ts | 463 ++++++++++++++++ .../ui/content/signin/signin.mdx | 11 + .../ui/content/signin/signin.ts | 69 +++ .../ui/content/signout/signout.mdx | 11 + .../ui/content/signout/signout.ts | 32 ++ .../ui/content/siteConfig.json | 6 +- .../ui/design/components.ts | 32 +- .../ui/design/plugin.ts | 68 ++- .../ui/design/styles.ts | 2 + .../ui/design/styles/admin.ts | 507 ++++++++++++++++++ .../ui/design/styles/forms.ts | 2 +- .../ui/design/styles/navigation.ts | 7 +- .../ui/tests/browser.ts | 60 ++- .../ui/tests/client.test.ts | 6 +- .../ui/tests/dashboard-model.test.ts | 2 + .../ui/tests/setup.ts | 12 +- .../ui/tests/tracker.ts | 30 +- .../aspnet-core/aspnet-core.mdx | 2 + .../getting-started/dashboard/dashboard.mdx | 35 +- .../reference/configuration/configuration.mdx | 2 +- docs-site/package.json | 2 +- .../AnalyticsAspNetCoreExtensions.cs | 26 +- .../DashboardAdministrationTests.cs | 350 ++++++++++++ .../DashboardAnalyticsOptionsTests.cs | 22 +- .../ZoneTree.Analytics.UnitTests.csproj | 9 +- src/ZoneTree.Analytics/AnalyticsOptions.cs | 9 +- 93 files changed, 7324 insertions(+), 507 deletions(-) create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Auth/PasswordHasher.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Auth/SessionStore.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Auth/UserStore.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineHost.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Http/DashboardJson.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Settings/DashboardSettings.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsDocument.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsPresets.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsSchema.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupBanner.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupKey.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/System/AuditLog.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/server/System/SystemStore.cs create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/account-settings.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/settings.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/components/settings-fields.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/components/sites-editor.ts delete mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.mdx delete mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/lib/admin-contracts.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/lib/admin.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/lib/page.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/lib/session.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/lib/settings-model.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/setup/setup.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/setup/setup.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/signin/signin.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/signin/signin.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/signout/signout.mdx create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/content/signout/signout.ts create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts create mode 100644 src/ZoneTree.Analytics.UnitTests/DashboardAdministrationTests.cs diff --git a/README.md b/README.md index b9e2f9f..bdcb7b0 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Then, from the repository root, start the server with sample data: yarn dashboard --Analytics:SeedDemo=true ``` -Open **http://127.0.0.1:5088/**. Development mode starts the UI automatically. Demo seeding populates an empty primary dataset; omit the flag to use your own data. The core library does not require Node.js or Yarn. +Open the setup link printed at the end of the server's console output and finish the setup wizard. Development mode starts the UI automatically. Demo seeding populates the empty primary site once setup is complete; omit the flag to use your own data. The core library does not require Node.js or Yarn. Read the [dashboard guide][dashboard] for prerequisites, static release builds, and deployment settings. diff --git a/apps/ZoneTree.Analytics.Dashboard/README.md b/apps/ZoneTree.Analytics.Dashboard/README.md index adf9a29..ba2ea9f 100644 --- a/apps/ZoneTree.Analytics.Dashboard/README.md +++ b/apps/ZoneTree.Analytics.Dashboard/README.md @@ -21,7 +21,39 @@ yarn dashboard Open **http://localhost:5088/**. Development mode starts PureStack automatically, with live reload for UI changes. The server serves the reporting API and tracker on the same origin. -Use `--Analytics:SeedDemo=true` to seed an empty primary dataset. +Use `--Analytics:SeedDemo=true` to seed an empty primary site. + +## First run + +A server without an administrator opens the setup wizard on every page. Once startup output has +settled (in Development, after PureStack's first render), the console ends with a box holding a +setup link and its one-time **setup key**. Open the link, or enter the key, to create the administrator, add your sites and their +domains, choose the reporting time zone and a collection preset, and optionally change any +other setting under **Advanced settings**. The key changes on every start and stops working +after setup. The time zone, data directory, write-ahead-log directory and visitor sketch +precision are stored with the data and cannot change later; everything else can change in the +admin panel. + +## Users and administration + +Every page, report and setting requires signing in; only `/_analytics/script.js` and +`/_analytics/event` are public. The dashboard has one administrator. Other users are analysts +(reports and the query explorer) or viewers (reports), each with access to all sites or to +assigned sites. The topbar account menu uses PureStack's sign-in component; its pages are +`/signin/`, `/signout/`, `/account/` and `/account/settings/`. + +The administrator's pages under `/admin/` show the engine status and manage sites, users and +their sessions, indexes and rollups, every engine and dashboard setting, and the audit log. +Saving settings first reviews the changes: some apply at once, engine options reopen the engine +for a few seconds, the reporting concurrency limit needs a restart, and changes that delete data +need confirmation. If the engine cannot open with new settings, the previous ones stay in use. + +Users, sessions (only SHA-256 hashes of their tokens), settings and the audit log are stored in +one ZoneTree under `server/data/system`; choose another directory with +`--Dashboard:SystemDirectory=`. After setup, these stored settings are the source of +truth for the analytics engine. Passwords are PBKDF2-SHA256 hashes. Session cookies are +HttpOnly and SameSite=Lax, marked Secure over HTTPS, and state-changing requests from other +origins are rejected. Serve the dashboard over HTTPS in production. The host listens on all network interfaces by default. Startup logs include a clickable local dashboard URL; other devices can connect using `http://:5088/`. @@ -29,10 +61,11 @@ dashboard URL; other devices can connect using `http://: For a static release, run `yarn workspace zonetree-analytics-dashboard publish` from the repository root, then start the server with `--Dashboard:Watch=false`. Its default static directory is `ui/dist/publish`. -All engine settings live in [DashboardAnalyticsOptions.cs](server/DashboardAnalyticsOptions.cs). -`ConfigureCommon` applies shared defaults; the per-engine methods configure actual traffic, -performance samples, new benchmarks and retained benchmark reports. Local datasets live -under `server/data`. The UI uses the repository's Yarn workspace, `node_modules`, and TypeScript 7. +The primary engine is configured from the stored settings +([SettingsMapper.cs](server/Settings/SettingsMapper.cs) maps them to `AnalyticsOptions`). +[DashboardAnalyticsOptions.cs](server/DashboardAnalyticsOptions.cs) configures the Development +performance samples, new benchmarks and retained benchmark reports. Local datasets live under +`server/data`. The UI uses the repository's Yarn workspace, `node_modules`, and TypeScript 7. See the [UI and application guide](ui/README.md) for reports, configuration, dataset tools, builds and verification. A small integration example lives in diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs b/apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs new file mode 100644 index 0000000..5ce3b70 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs @@ -0,0 +1,114 @@ +sealed record SettingsSaveRequest(DashboardSettings Settings, bool ConfirmDataLoss); +sealed record SettingsReviewRequest(DashboardSettings Settings); +sealed record ChangeView(string Key, string Label, SettingTier Tier); + +// The administrator's API: settings, users and their sessions, engine status and the audit log. +static class AdminEndpoints +{ + public static void MapDashboardAdmin(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/_dashboard/api/admin").WithDashboardErrors() + .RequireAuthorization(DashboardAuthentication.AdministratorPolicy); + + admin.MapGet("/schema", () => DashboardJson.Ok(SettingsSchema.Document)); + admin.MapGet("/settings", (SettingsService settings) => + DashboardJson.Ok(new { settings = settings.Current, restartRequired = settings.RestartRequired })); + admin.MapPost("/settings/review", async (HttpContext context, SettingsService settings) => + { + var request = await DashboardJson.ReadAsync(context.Request); + var review = settings.Review(request.Settings); + return DashboardJson.Ok(new { changes = Changes(review.Changes), warnings = review.Warnings }); + }); + admin.MapPut("/settings", SaveSettingsAsync); + admin.MapGet("/status", (SettingsService settings, EngineHost engines, UserStore users, SystemStore store) => + DashboardJson.Ok(new + { + engine = engines.GetStatus(includePartitions: true), + restartRequired = settings.RestartRequired, + dataDirectory = settings.Current == null ? null : SettingsMapper.ResolveDataDirectory(settings.Current, settings.ContentRoot), + systemDirectory = store.Directory, + users = users.List().Count, + sites = settings.Current?.Sites.Items.Count ?? 0, + })); + + admin.MapGet("/users", (UserStore users) => DashboardJson.Ok(users.List().Select(UserView.From))); + admin.MapPost("/users", async (HttpContext context, UserStore users, SettingsService settings, AuditLog audit) => + { + var change = await DashboardJson.ReadAsync(context.Request); + var user = users.Create(change, AuthEndpoints.KnownSites(settings)); + audit.Write(Actor(context), "user.created", $"{user.UserName} ({user.Role})", context); + return DashboardJson.Ok(UserView.From(user)); + }); + admin.MapPut("/users/{id}", async (string id, HttpContext context, UserStore users, SettingsService settings, AuditLog audit) => + { + var change = await DashboardJson.ReadAsync(context.Request); + var user = users.Update(id, change, AuthEndpoints.KnownSites(settings)); + audit.Write(Actor(context), "user.updated", Describe(user.UserName, change), context); + return DashboardJson.Ok(UserView.From(user)); + }); + admin.MapDelete("/users/{id}", (string id, HttpContext context, UserStore users, SessionStore sessions, AuditLog audit) => + { + var user = users.Get(id) ?? throw new UserStoreException("The user does not exist."); + users.Delete(id); + sessions.RevokeAll(id); + audit.Write(Actor(context), "user.deleted", user.UserName, context); + return Results.NoContent(); + }); + admin.MapGet("/users/{id}/sessions", (string id, HttpContext context, SessionStore sessions) => + DashboardJson.Ok(sessions.List(id, DashboardAuthentication.SessionId(context)))); + admin.MapDelete("/users/{id}/sessions", (string id, HttpContext context, UserStore users, SessionStore sessions, AuditLog audit) => + { + var user = users.Get(id) ?? throw new UserStoreException("The user does not exist."); + sessions.RevokeAll(id, id == DashboardAuthentication.User(context).Id ? DashboardAuthentication.SessionId(context) : null); + audit.Write(Actor(context), "user.sessions-ended", user.UserName, context); + return Results.NoContent(); + }); + admin.MapDelete("/sessions/{id}", (string id, HttpContext context, SessionStore sessions, AuditLog audit) => + { + if (!sessions.Revoke(id, null)) return DashboardJson.Error("The session does not exist.", StatusCodes.Status404NotFound); + audit.Write(Actor(context), "session.ended", null, context); + return Results.NoContent(); + }); + + admin.MapGet("/audit", (string before, int? take, AuditLog audit) => DashboardJson.Ok(audit.List(before, take ?? 100))); + } + + static async Task SaveSettingsAsync(HttpContext context, SettingsService settings) + { + var request = await DashboardJson.ReadAsync(context.Request); + try + { + var result = await settings.SaveAsync(request.Settings, Actor(context), request.ConfirmDataLoss, context); + return DashboardJson.Ok(new + { + settings = result.Settings, + changes = Changes(result.Changes), + reopened = result.Reopened, + restartRequired = result.RestartRequired, + }); + } + catch (Exception error) when (error is not (SettingsValidationException or SettingsConfirmationRequiredException + or DashboardRequestException)) + { + return DashboardJson.Error($"The analytics engine could not open with these settings, so the previous settings stay in use: {error.Message}", + StatusCodes.Status422UnprocessableEntity); + } + } + + static IEnumerable Changes(IEnumerable changes) => + changes.Select(change => new ChangeView(change.Key, change.Label, change.Tier)); + + static string Actor(HttpContext context) => DashboardAuthentication.User(context)?.UserName; + + static string Describe(string userName, UserChange change) + { + var parts = new List(); + if (change.UserName != null) parts.Add("user name"); + if (change.DisplayName != null) parts.Add("display name"); + if (change.Role != null) parts.Add($"role {change.Role}"); + if (change.AllSites != null || change.Sites != null) parts.Add("sites"); + if (change.Disabled != null) parts.Add(change.Disabled.Value ? "disabled" : "enabled"); + if (change.Password != null) parts.Add("password reset"); + return $"{userName}: {string.Join(", ", parts)}"; + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs new file mode 100644 index 0000000..fe09309 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs @@ -0,0 +1,111 @@ +sealed record SignInRequest(string UserName, string Password); +sealed record ProfileRequest(string DisplayName); +sealed record PasswordRequest(string CurrentPassword, string NewPassword); + +sealed record SiteView(string Id, string Name); + +sealed record MeResponse(UserView User, IReadOnlyList Sites, string SessionId); + +static class AuthEndpoints +{ + const string SignInScope = "signin"; + + public static void MapDashboardAuth(this IEndpointRouteBuilder app) + { + var auth = app.MapGroup("/_dashboard/api/auth").WithDashboardErrors(); + auth.MapPost("/signin", SignInAsync).AllowAnonymous(); + auth.MapPost("/signout", SignOut).AllowAnonymous(); + var me = auth.MapGroup("/me").RequireAuthorization(); + me.MapGet("", (HttpContext context, SettingsService settings) => DashboardJson.Ok(Me(context, settings))); + me.MapPut("/profile", UpdateProfileAsync); + me.MapPost("/password", ChangePasswordAsync); + me.MapGet("/sessions", (HttpContext context, SessionStore sessions) => + DashboardJson.Ok(sessions.List(DashboardAuthentication.User(context).Id, DashboardAuthentication.SessionId(context)))); + me.MapDelete("/sessions/{id}", (string id, HttpContext context, SessionStore sessions) => + sessions.Revoke(id, DashboardAuthentication.User(context).Id) ? Results.NoContent() : DashboardJson.Error("The session does not exist.", 404)); + } + + public static MeResponse Me(HttpContext context, SettingsService settings, UserRecord user = null, string sessionId = null) + { + user ??= DashboardAuthentication.User(context); + var sites = settings.Current?.Sites.Items.Where(site => user.CanAccess(site.Id)).Select(site => new SiteView(site.Id, site.Name)).ToList() ?? []; + return new(UserView.From(user), sites, sessionId ?? DashboardAuthentication.SessionId(context)); + } + + static async Task SignInAsync(HttpContext context, UserStore users, SessionStore sessions, SignInThrottle throttle, + SetupStatus setup, SettingsService settings, AuditLog audit) + { + if (!setup.Complete) return DashboardJson.Error("Finish the setup wizard first.", StatusCodes.Status409Conflict); + var request = await DashboardJson.ReadAsync(context.Request); + var userName = request.UserName?.Trim() ?? ""; + if (userName.Length > 64) userName = userName[..64]; + if (throttle.Paused(SignInScope, userName, context) is { } wait) + return TooManyAttempts(context, wait); + var user = users.FindByName(userName); + var valid = PasswordHasher.Verify(request.Password, user?.PasswordHash) && user is { Disabled: false }; + if (!valid) + { + throttle.Failed(SignInScope, userName, context); + audit.Write(userName, "auth.signin-failed", user == null ? "Unknown user name." : user.Disabled ? "The account is disabled." : "Wrong password.", context); + return DashboardJson.Error("The user name or password is incorrect.", StatusCodes.Status401Unauthorized); + } + throttle.Succeeded(SignInScope, userName, context); + var (token, session) = sessions.Create(user, context); + SessionStore.WriteCookie(context, token, session.Expires); + users.RecordSignIn(user); + audit.Write(user.UserName, "auth.signin", null, context); + return DashboardJson.Ok(Me(context, settings, user, SessionStore.Id(token))); + } + + public static IResult TooManyAttempts(HttpContext context, TimeSpan wait) + { + var seconds = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)); + context.Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + return DashboardJson.Error($"Too many failed attempts. Try again in {Math.Ceiling(seconds / 60.0)} minute(s).", + StatusCodes.Status429TooManyRequests); + } + + static IResult SignOut(HttpContext context, SessionStore sessions, AuditLog audit) + { + var token = context.Request.Cookies[SessionStore.CookieName]; + if (!string.IsNullOrEmpty(token) && sessions.TryAuthenticate(token, out var id, out var user)) + { + sessions.Revoke(id, user.Id); + audit.Write(user.UserName, "auth.signout", null, context); + } + SessionStore.DeleteCookie(context); + return Results.NoContent(); + } + + static async Task UpdateProfileAsync(HttpContext context, UserStore users, SettingsService settings) + { + var request = await DashboardJson.ReadAsync(context.Request); + var user = DashboardAuthentication.User(context); + var updated = users.Update(user.Id, new UserChange(null, request.DisplayName ?? "", null, null, null, null, null), KnownSites(settings)); + return DashboardJson.Ok(Me(context, settings, updated)); + } + + // A new password ends every session, then signs this browser in again. + static async Task ChangePasswordAsync(HttpContext context, UserStore users, SessionStore sessions, + SignInThrottle throttle, SettingsService settings, AuditLog audit) + { + var request = await DashboardJson.ReadAsync(context.Request); + var user = DashboardAuthentication.User(context); + if (throttle.Paused("password", user.Id, context) is { } wait) + return TooManyAttempts(context, wait); + if (!PasswordHasher.Verify(request.CurrentPassword, user.PasswordHash)) + { + throttle.Failed("password", user.Id, context); + return DashboardJson.Error("The current password is incorrect."); + } + var updated = users.Update(user.Id, new UserChange(null, null, null, null, null, null, request.NewPassword ?? ""), KnownSites(settings)); + sessions.RevokeAll(user.Id); + var (token, session) = sessions.Create(updated, context); + SessionStore.WriteCookie(context, token, session.Expires); + audit.Write(user.UserName, "auth.password-changed", "Other sessions ended.", context); + return DashboardJson.Ok(Me(context, settings, updated, SessionStore.Id(token))); + } + + public static HashSet KnownSites(SettingsService settings) => + settings.Current?.Sites.Items.Select(site => site.Id).ToHashSet(StringComparer.Ordinal) ?? []; +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs new file mode 100644 index 0000000..04fa131 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +static class DashboardAuthentication +{ + public const string Scheme = "ZoneTree"; + public const string AdministratorPolicy = "Administrator"; + public const string QueryPolicy = "Query"; + const string UserItem = "zt-dashboard-user"; + const string SessionItem = "zt-dashboard-session"; + + public static UserRecord User(HttpContext context) => context.Items[UserItem] as UserRecord; + + public static string SessionId(HttpContext context) => context.Items[SessionItem] as string; + + public static void SetUser(HttpContext context, UserRecord user, string sessionId) + { + context.Items[UserItem] = user; + context.Items[SessionItem] = sessionId; + } + + public static ClaimsPrincipal Principal(UserRecord user) => new(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Role, user.Role.ToString()), + ], Scheme)); + + public static IServiceCollection AddDashboardAuthentication(this IServiceCollection services) + { + services.AddAuthentication(Scheme).AddScheme(Scheme, null); + services.AddAuthorizationBuilder() + .AddPolicy(AdministratorPolicy, policy => policy.RequireRole(nameof(UserRole.Administrator))) + .AddPolicy(QueryPolicy, policy => policy.RequireRole(nameof(UserRole.Administrator), nameof(UserRole.Analyst))); + return services; + } +} + +// Authenticates the session cookie against the system store. API challenges answer JSON. +sealed class DashboardAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, + UrlEncoder encoder, SessionStore sessions) : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + var token = Request.Cookies[SessionStore.CookieName]; + if (string.IsNullOrEmpty(token)) return Task.FromResult(AuthenticateResult.NoResult()); + if (!sessions.TryAuthenticate(token, out var id, out var user)) + { + SessionStore.DeleteCookie(Context); + return Task.FromResult(AuthenticateResult.NoResult()); + } + DashboardAuthentication.SetUser(Context, user, id); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(DashboardAuthentication.Principal(user), Scheme.Name))); + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.Headers.CacheControl = "no-store"; + await Response.WriteAsJsonAsync(new { error = "Sign in to continue." }); + } + + protected override async Task HandleForbiddenAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status403Forbidden; + Response.Headers.CacheControl = "no-store"; + await Response.WriteAsJsonAsync(new { error = "Your role does not allow this. Ask the administrator for access." }); + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs new file mode 100644 index 0000000..18fc5d3 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs @@ -0,0 +1,77 @@ +// Whether the setup wizard has created the administrator. Cached: checked on every page request. +sealed class SetupStatus(UserStore users, SettingsService settings) +{ + volatile bool complete = settings.IsConfigured && users.HasAdministrator; + + public bool Complete => complete; + + public void MarkComplete() => complete = true; +} + +// Rejects state-changing dashboard and reporting requests sent from other sites. The session +// cookie is SameSite=Lax; this covers the rest. +sealed class SameOriginMiddleware(RequestDelegate next) +{ + public Task InvokeAsync(HttpContext context) + { + var request = context.Request; + if (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method) || HttpMethods.IsOptions(request.Method) + || !(request.Path.StartsWithSegments("/_dashboard") || request.Path.StartsWithSegments(EngineMiddleware.ApiPath) + || request.Path.StartsWithSegments("/_analytics/sample-data"))) + return next(context); + var origin = request.Headers.Origin.ToString(); + var fetchSite = request.Headers["Sec-Fetch-Site"].ToString(); + if ((origin.Length > 0 && origin != $"{request.Scheme}://{request.Host}") || fetchSite is "cross-site" or "same-site") + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return context.Response.WriteAsJsonAsync(new { error = "Use the dashboard from this host." }); + } + return next(context); + } +} + +// Sends page requests where they belong: to the setup wizard until it is finished, to sign-in +// without a session, and away from pages the user's role cannot use. APIs authorize themselves. +sealed class PageGateMiddleware(RequestDelegate next, SetupStatus setup) +{ + static readonly HashSet PublicPages = new(StringComparer.OrdinalIgnoreCase) { "/signin/", "/signout/" }; + + public Task InvokeAsync(HttpContext context) + { + var request = context.Request; + var path = request.Path.Value ?? "/"; + if (!(HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method)) || path.StartsWith("/_", StringComparison.Ordinal)) + return next(context); + var extension = Path.GetExtension(path); + if (extension.Length > 0 && !extension.Equals(".html", StringComparison.OrdinalIgnoreCase)) + return next(context); + var page = PageOf(path); + if (!setup.Complete) + return page == "/setup/" ? next(context) : Redirect(context, "/setup/"); + if (page == "/setup/") return Redirect(context, "/"); + if (PublicPages.Contains(page)) return next(context); + var user = DashboardAuthentication.User(context); + if (user == null) + return Redirect(context, "/signin/?returnUrl=" + Uri.EscapeDataString(path + request.QueryString)); + if (!user.IsAdministrator && (page.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase) || page == "/sample-data/")) + return Redirect(context, "/"); + if (!user.CanQuery && page == "/explorer/") + return Redirect(context, "/"); + return next(context); + } + + // "/admin/users/index.html" and "/admin/users" → "/admin/users/". + static string PageOf(string path) + { + if (path.EndsWith("/index.html", StringComparison.OrdinalIgnoreCase)) path = path[..^"index.html".Length]; + else if (path.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) path = path[..^".html".Length] + "/"; + return path.EndsWith('/') ? path : path + "/"; + } + + static Task Redirect(HttpContext context, string location) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Redirect(location); + return Task.CompletedTask; + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/PasswordHasher.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/PasswordHasher.cs new file mode 100644 index 0000000..daaa967 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/PasswordHasher.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using System.Security.Cryptography; + +// PBKDF2-SHA256 hashes in a self-describing format: pbkdf2-sha256$iterations$salt$hash. +static class PasswordHasher +{ + const string Algorithm = "pbkdf2-sha256"; + const int Iterations = 600_000; + const int SaltBytes = 16; + const int HashBytes = 32; + public const int MinLength = 10; + public const int MaxLength = 256; + + // Verifying against it keeps an unknown user name as slow as a wrong password. + static readonly string Dummy = Hash("unused-dummy-password"); + + public static string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashBytes); + return string.Join('$', Algorithm, Iterations.ToString(CultureInfo.InvariantCulture), + Convert.ToBase64String(salt), Convert.ToBase64String(hash)); + } + + public static bool Verify(string password, string encoded) + { + var parts = (encoded ?? Dummy).Split('$'); + if (parts.Length != 4 || parts[0] != Algorithm + || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var iterations) || iterations < 1) + return false; + var salt = Convert.FromBase64String(parts[2]); + var expected = Convert.FromBase64String(parts[3]); + var actual = Rfc2898DeriveBytes.Pbkdf2(password ?? "", salt, iterations, HashAlgorithmName.SHA256, expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected) && encoded != null; + } + + public static string ValidateStrength(string password, string userName) + { + if (string.IsNullOrEmpty(password) || password.Length < MinLength) + return $"Use at least {MinLength} characters for the password."; + if (password.Length > MaxLength) + return $"Use at most {MaxLength} characters for the password."; + if (string.Equals(password, userName, StringComparison.OrdinalIgnoreCase)) + return "The password cannot be the user name."; + return null; + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/SessionStore.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/SessionStore.cs new file mode 100644 index 0000000..64e4d8f --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/SessionStore.cs @@ -0,0 +1,155 @@ +using System.Security.Cryptography; +using System.Text; + +sealed class SessionRecord +{ + public string UserId { get; set; } + public string SecurityStamp { get; set; } + public DateTime Created { get; set; } + public DateTime LastSeen { get; set; } + public DateTime Expires { get; set; } + public string IpAddress { get; set; } + public string UserAgent { get; set; } +} + +// The id is the SHA-256 of the token: it names a session without granting access. +sealed record SessionView(string Id, DateTime Created, DateTime LastSeen, DateTime Expires, string IpAddress, + string UserAgent, bool Current); + +// Sessions are random 256-bit tokens kept in a cookie; the store keeps only their SHA-256. +sealed class SessionStore(SystemStore store, UserStore users, SettingsService settings) +{ + const string Prefix = "session/"; + public const string CookieName = "zt_session"; + static readonly TimeSpan TouchInterval = TimeSpan.FromMinutes(1); + + public static string Id(string token) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(); + + public (string Token, SessionRecord Session) Create(UserRecord user, HttpContext context) + { + var token = Base64Url(RandomNumberGenerator.GetBytes(32)); + var now = DateTime.UtcNow; + var security = settings.Security; + var session = new SessionRecord + { + UserId = user.Id, + SecurityStamp = user.SecurityStamp, + Created = now, + LastSeen = now, + Expires = now.AddDays(security.SessionLifetimeDays), + IpAddress = context.Connection.RemoteIpAddress?.ToString(), + UserAgent = Truncate(context.Request.Headers.UserAgent.ToString(), 300), + }; + store.Put(Prefix + Id(token), session); + return (token, session); + } + + public bool TryAuthenticate(string token, out string id, out UserRecord user) + { + user = null; + id = null; + if (string.IsNullOrEmpty(token) || token.Length > 100) return false; + id = Id(token); + if (!store.TryGet(Prefix + id, out var session)) return false; + var now = DateTime.UtcNow; + if (IsExpired(session, now)) + { + store.Delete(Prefix + id); + return false; + } + user = users.Get(session.UserId); + if (user == null || user.Disabled || user.SecurityStamp != session.SecurityStamp) + { + store.Delete(Prefix + id); + user = null; + return false; + } + if (now - session.LastSeen >= TouchInterval) + { + session.LastSeen = now; + store.Put(Prefix + id, session); + } + return true; + } + + bool IsExpired(SessionRecord session, DateTime now) => + now >= session.Expires || now - session.LastSeen >= TimeSpan.FromHours(settings.Security.SessionIdleHours); + + public List List(string userId, string currentId) => + [.. store.List(Prefix) + .Where(entry => entry.Value.UserId == userId && !IsExpired(entry.Value, DateTime.UtcNow)) + .Select(entry => View(entry.Key[Prefix.Length..], entry.Value, currentId)) + .OrderByDescending(session => session.LastSeen)]; + + static SessionView View(string id, SessionRecord session, string currentId) => + new(id, session.Created, session.LastSeen, session.Expires, session.IpAddress, session.UserAgent, id == currentId); + + public bool Revoke(string id, string userId) + { + if (string.IsNullOrEmpty(id) || !store.TryGet(Prefix + id, out var session)) return false; + if (userId != null && session.UserId != userId) return false; + store.Delete(Prefix + id); + return true; + } + + public void RevokeAll(string userId, string exceptId = null) + { + foreach (var entry in store.List(Prefix)) + if (entry.Value.UserId == userId && entry.Key[Prefix.Length..] != exceptId) + store.Delete(entry.Key); + } + + // Removes expired sessions and the sessions of users that no longer exist or whose sessions ended. + public int Cleanup() + { + var now = DateTime.UtcNow; + var removed = 0; + foreach (var entry in store.List(Prefix)) + { + var user = users.Get(entry.Value.UserId); + if (!IsExpired(entry.Value, now) && user != null && !user.Disabled && user.SecurityStamp == entry.Value.SecurityStamp) + continue; + store.Delete(entry.Key); + removed++; + } + return removed; + } + + public static void WriteCookie(HttpContext context, string token, DateTime expires) => + context.Response.Cookies.Append(CookieName, token, new CookieOptions + { + HttpOnly = true, + // Lax keeps links into the dashboard signed in; unsafe requests also need a matching Origin. + SameSite = SameSiteMode.Lax, + Secure = context.Request.IsHttps, + Path = "/", + Expires = expires, + IsEssential = true, + }); + + public static void DeleteCookie(HttpContext context) => + context.Response.Cookies.Delete(CookieName, new CookieOptions { Path = "/", Secure = context.Request.IsHttps, SameSite = SameSiteMode.Lax }); + + static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + static string Truncate(string value, int length) => string.IsNullOrEmpty(value) || value.Length <= length ? value : value[..length]; +} + +sealed class SessionCleanup(SessionStore sessions, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + do + { + try + { + var removed = sessions.Cleanup(); + if (removed > 0) logger.LogInformation("Removed {Count} ended dashboard sessions.", removed); + } + catch (Exception error) { logger.LogError(error, "Dashboard session cleanup failed."); } + } + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs new file mode 100644 index 0000000..8351ca4 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs @@ -0,0 +1,58 @@ +using System.Collections.Concurrent; + +// Pauses sign-in for a user name and client address after repeated failures. Kept in memory: +// a restart clears it. +sealed class SignInThrottle(SettingsService settings) +{ + sealed class Attempts + { + public int Failures; + public DateTime WindowStart; + public DateTime PausedUntil; + } + + readonly ConcurrentDictionary attempts = new(StringComparer.Ordinal); + + static string Key(string scope, string name, HttpContext context) => + scope + "\n" + (name ?? "").Trim().ToLowerInvariant() + "\n" + context.Connection.RemoteIpAddress; + + // The time left in a pause, or null when an attempt may proceed. + public TimeSpan? Paused(string scope, string name, HttpContext context) + { + if (!attempts.TryGetValue(Key(scope, name, context), out var entry)) return null; + lock (entry) + { + var left = entry.PausedUntil - DateTime.UtcNow; + return left > TimeSpan.Zero ? left : null; + } + } + + public void Failed(string scope, string name, HttpContext context) + { + var security = settings.Security; + var now = DateTime.UtcNow; + var window = TimeSpan.FromMinutes(security.SignInLockoutMinutes); + var entry = attempts.GetOrAdd(Key(scope, name, context), _ => new Attempts { WindowStart = now }); + lock (entry) + { + if (now - entry.WindowStart > window) { entry.Failures = 0; entry.WindowStart = now; } + if (++entry.Failures >= security.MaxFailedSignIns) + { + entry.PausedUntil = now + window; + entry.Failures = 0; + entry.WindowStart = now; + } + } + if (attempts.Count > 100_000) Prune(now, window); + } + + public void Succeeded(string scope, string name, HttpContext context) => attempts.TryRemove(Key(scope, name, context), out _); + + void Prune(DateTime now, TimeSpan window) + { + foreach (var (key, entry) in attempts) + lock (entry) + if (entry.PausedUntil < now && now - entry.WindowStart > window) + attempts.TryRemove(key, out _); + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/UserStore.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/UserStore.cs new file mode 100644 index 0000000..6af3126 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/UserStore.cs @@ -0,0 +1,226 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +enum UserRole { Administrator, Analyst, Viewer } + +sealed class UserRecord +{ + public string Id { get; set; } + public string UserName { get; set; } + public string DisplayName { get; set; } + public string PasswordHash { get; set; } + public UserRole Role { get; set; } + // Non-administrators read every site, or only the listed ones. + public bool AllSites { get; set; } + public List Sites { get; set; } = []; + public bool Disabled { get; set; } + // Changing it ends every session of the user. + public string SecurityStamp { get; set; } + public DateTime Created { get; set; } + public DateTime? LastSignIn { get; set; } + + public bool IsAdministrator => Role == UserRole.Administrator; + + public bool CanQuery => Role is UserRole.Administrator or UserRole.Analyst; + + // Dashboard usage and the Development datasets are the administrator's. + public bool CanAccess(string site) => IsAdministrator + || (!SettingsMapper.ReservedSites.Contains(site) && (AllSites || Sites.Contains(site, StringComparer.Ordinal))); +} + +// The public view of a user: no password hash or security stamp. +sealed record UserView(string Id, string UserName, string DisplayName, UserRole Role, bool AllSites, + IReadOnlyList Sites, bool Disabled, DateTime Created, DateTime? LastSignIn) +{ + public static UserView From(UserRecord user) => new(user.Id, user.UserName, user.DisplayName, user.Role, + user.IsAdministrator || user.AllSites, user.Sites, user.Disabled, user.Created, user.LastSignIn); +} + +sealed record UserChange(string UserName, string DisplayName, UserRole? Role, bool? AllSites, List Sites, + bool? Disabled, string Password); + +sealed class UserStoreException(string message) : Exception(message); + +sealed partial class UserStore(SystemStore store) +{ + const string UserPrefix = "user/"; + const string NamePrefix = "username/"; + + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$")] + private static partial Regex UserNamePattern(); + + static string NameKey(string userName) => NamePrefix + userName.ToLowerInvariant(); + + public bool HasAdministrator => List().Any(user => user.IsAdministrator); + + public UserRecord Get(string id) => id != null && store.TryGet(UserPrefix + id, out var user) ? user : null; + + public UserRecord FindByName(string userName) + { + if (string.IsNullOrWhiteSpace(userName)) return null; + return store.TryGet(NameKey(userName.Trim()), out var id) ? Get(id) : null; + } + + public List List() => + [.. store.List(UserPrefix).Select(entry => entry.Value).OrderBy(user => user.UserName, StringComparer.OrdinalIgnoreCase)]; + + public UserRecord Create(UserChange change, ISet knownSites) + { + lock (store.WriteLock) + { + var role = change.Role ?? UserRole.Viewer; + if (role == UserRole.Administrator && HasAdministrator) + throw new UserStoreException("The dashboard has one administrator. Choose the analyst or viewer role."); + var userName = ValidateUserName(change.UserName); + if (store.Contains(NameKey(userName))) + throw new UserStoreException($"The user name '{userName}' is taken."); + var problem = PasswordHasher.ValidateStrength(change.Password, userName); + if (problem != null) throw new UserStoreException(problem); + var user = new UserRecord + { + Id = Guid.NewGuid().ToString("N"), + UserName = userName, + DisplayName = ValidateDisplayName(change.DisplayName, userName), + PasswordHash = PasswordHasher.Hash(change.Password), + Role = role, + AllSites = role == UserRole.Administrator || (change.AllSites ?? false), + Sites = role == UserRole.Administrator ? [] : ValidateSites(change.Sites, knownSites), + SecurityStamp = NewStamp(), + Created = DateTime.UtcNow, + }; + store.Put(UserPrefix + user.Id, user); + store.Put(NameKey(userName), user.Id); + return user; + } + } + + // Applies the non-null members of the change. Role, site, password and status changes end + // the user's sessions. + public UserRecord Update(string id, UserChange change, ISet knownSites) + { + lock (store.WriteLock) + { + var user = Get(id) ?? throw new UserStoreException("The user does not exist."); + var endSessions = false; + if (change.UserName != null && !string.Equals(change.UserName.Trim(), user.UserName, StringComparison.Ordinal)) + { + var userName = ValidateUserName(change.UserName); + if (!string.Equals(userName, user.UserName, StringComparison.OrdinalIgnoreCase) && store.Contains(NameKey(userName))) + throw new UserStoreException($"The user name '{userName}' is taken."); + store.Delete(NameKey(user.UserName)); + store.Put(NameKey(userName), user.Id); + user.UserName = userName; + } + if (change.DisplayName != null) + user.DisplayName = ValidateDisplayName(change.DisplayName, user.UserName); + if (change.Role is { } role && role != user.Role) + { + if (user.IsAdministrator) + throw new UserStoreException("The administrator's role cannot change."); + if (role == UserRole.Administrator) + throw new UserStoreException("The dashboard has one administrator. Choose the analyst or viewer role."); + user.Role = role; + endSessions = true; + } + if (!user.IsAdministrator) + { + if (change.AllSites is { } allSites && allSites != user.AllSites) { user.AllSites = allSites; endSessions = true; } + if (change.Sites != null) + { + var sites = ValidateSites(change.Sites, knownSites); + if (!sites.SequenceEqual(user.Sites, StringComparer.Ordinal)) { user.Sites = sites; endSessions = true; } + } + } + if (change.Disabled is { } disabled && disabled != user.Disabled) + { + if (user.IsAdministrator && disabled) + throw new UserStoreException("The administrator cannot be disabled."); + user.Disabled = disabled; + endSessions = true; + } + if (change.Password != null) + { + var problem = PasswordHasher.ValidateStrength(change.Password, user.UserName); + if (problem != null) throw new UserStoreException(problem); + user.PasswordHash = PasswordHasher.Hash(change.Password); + endSessions = true; + } + if (endSessions) user.SecurityStamp = NewStamp(); + store.Put(UserPrefix + user.Id, user); + return user; + } + } + + public void Delete(string id) + { + lock (store.WriteLock) + { + var user = Get(id) ?? throw new UserStoreException("The user does not exist."); + if (user.IsAdministrator) + throw new UserStoreException("The administrator cannot be deleted."); + store.Delete(NameKey(user.UserName)); + store.Delete(UserPrefix + id); + } + } + + public void RecordSignIn(UserRecord user) + { + lock (store.WriteLock) + { + var current = Get(user.Id); + if (current == null) return; + current.LastSignIn = DateTime.UtcNow; + store.Put(UserPrefix + current.Id, current); + } + } + + // Sites removed from the settings disappear from every user's list. + public void RemoveSites(ISet knownSites) + { + lock (store.WriteLock) + { + foreach (var user in List()) + { + var sites = user.Sites.Where(knownSites.Contains).ToList(); + if (sites.Count == user.Sites.Count) continue; + user.Sites = sites; + store.Put(UserPrefix + user.Id, user); + } + } + } + + // Checks a new account's name, display name and password without storing anything. + public static void ValidateNew(UserChange change) + { + var userName = ValidateUserName(change.UserName); + ValidateDisplayName(change.DisplayName, userName); + var problem = PasswordHasher.ValidateStrength(change.Password, userName); + if (problem != null) throw new UserStoreException(problem); + } + + static string NewStamp() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + + static string ValidateUserName(string userName) + { + userName = userName?.Trim(); + if (string.IsNullOrEmpty(userName) || !UserNamePattern().IsMatch(userName)) + throw new UserStoreException("Use 3 to 64 letters, digits, dots, dashes, underscores or @ for the user name, starting with a letter or digit."); + return userName; + } + + static string ValidateDisplayName(string displayName, string userName) + { + displayName = string.IsNullOrWhiteSpace(displayName) ? userName : displayName.Trim(); + if (displayName.Length > 100 || displayName.Any(char.IsControl)) + throw new UserStoreException("Use at most 100 printable characters for the display name."); + return displayName; + } + + static List ValidateSites(List sites, ISet knownSites) + { + var result = (sites ?? []).Distinct(StringComparer.Ordinal).OrderBy(site => site, StringComparer.Ordinal).ToList(); + var unknown = result.FirstOrDefault(site => !knownSites.Contains(site)); + if (unknown != null) throw new UserStoreException($"The site '{unknown}' does not exist."); + return result; + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/DashboardAnalyticsOptions.cs b/apps/ZoneTree.Analytics.Dashboard/server/DashboardAnalyticsOptions.cs index 569b3be..80851f0 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/DashboardAnalyticsOptions.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/DashboardAnalyticsOptions.cs @@ -3,15 +3,17 @@ using ZoneTree.Analytics.AspNetCore; using ZoneTree.Analytics.Web; -// Edit engine configuration here. Every engine gets its own options and collections. +// Engine configuration of the Development sample and benchmark datasets. Every engine gets its +// own options and collections. The primary engine is configured from the stored dashboard +// settings (see SettingsMapper). static class DashboardAnalyticsOptions { public const string DefaultDataDirectory = "data/analytics"; public const string PerformanceSite = "performance-samples"; public const string BenchmarkSite = "benchmark-results"; - // Shared defaults for actual traffic, generated samples and benchmark datasets. - // Set retention, storage, rollups, query and ingestion tuning here for all engines. + // Shared defaults for generated samples and benchmark datasets. + // Set retention, storage, rollups, query and ingestion tuning here for both. static void ConfigureCommon(AnalyticsOptions options) { options.Retention.RawEvents = TimeSpan.FromDays(365 * 3); @@ -21,15 +23,6 @@ static void ConfigureCommon(AnalyticsOptions options) ConfigureIndexes(options); } - public static void ConfigurePrimary(AnalyticsOptions options, string directory, TimeZoneInfo timeZone) - { - ConfigureCommon(options); - options.DataDirectory = directory; - options.TimeZone = timeZone; - options.Rollups.ValueAggregates["load_time"] = ValueAggregates.Percentiles; - // ASP.NET Core registration supplies the primary engine's host logger. - } - public static AnalyticsOptions CreatePerformanceSamples(string directory, TimeZoneInfo timeZone, int workers, ILogger logger = null) { diff --git a/apps/ZoneTree.Analytics.Dashboard/server/DashboardDevelopmentServer.cs b/apps/ZoneTree.Analytics.Dashboard/server/DashboardDevelopmentServer.cs index ae067f9..8ea4b00 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/DashboardDevelopmentServer.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/DashboardDevelopmentServer.cs @@ -16,6 +16,10 @@ sealed class DashboardDevelopmentServer( }; Process process; Uri origin; + long lastOutputTicks = DateTime.UtcNow.Ticks; + + // When PureStack last printed anything: its first full render keeps logging after it serves. + public DateTime LastOutputUtc => new(Interlocked.Read(ref lastOutputTicks), DateTimeKind.Utc); public async Task StartAsync(CancellationToken cancellationToken) { @@ -38,8 +42,18 @@ public async Task StartAsync(CancellationToken cancellationToken) foreach (var argument in new[] { cli, "serve", "--content", "./content", "--host", "127.0.0.1", "--port", port.ToString(), "--full-render" }) start.ArgumentList.Add(argument); process = new Process { StartInfo = start, EnableRaisingEvents = true }; - process.OutputDataReceived += (_, line) => { if (!string.IsNullOrWhiteSpace(line.Data)) logger.LogInformation("PureStack: {Line}", line.Data); }; - process.ErrorDataReceived += (_, line) => { if (!string.IsNullOrWhiteSpace(line.Data)) logger.LogWarning("PureStack: {Line}", line.Data); }; + process.OutputDataReceived += (_, line) => + { + if (string.IsNullOrWhiteSpace(line.Data)) return; + Interlocked.Exchange(ref lastOutputTicks, DateTime.UtcNow.Ticks); + logger.LogInformation("PureStack: {Line}", line.Data); + }; + process.ErrorDataReceived += (_, line) => + { + if (string.IsNullOrWhiteSpace(line.Data)) return; + Interlocked.Exchange(ref lastOutputTicks, DateTime.UtcNow.Ticks); + logger.LogWarning("PureStack: {Line}", line.Data); + }; process.Exited += (_, _) => { if (!lifetime.ApplicationStopping.IsCancellationRequested) @@ -90,8 +104,8 @@ static string ResolvePureStackCli(string source) public async Task ForwardAsync(HttpContext context) { - // Reporting and tracker routes are handled by ASP.NET Core, including misses. - if (context.Request.Path.StartsWithSegments("/_analytics")) + // Reporting, tracker and dashboard API routes are handled by ASP.NET Core, including misses. + if (context.Request.Path.StartsWithSegments("/_analytics") || context.Request.Path.StartsWithSegments("/_dashboard")) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineHost.cs b/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineHost.cs new file mode 100644 index 0000000..54539d2 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineHost.cs @@ -0,0 +1,204 @@ +using ZoneTree.Analytics; + +enum EngineState { NotConfigured, Opening, Running, Reopening, Failed } + +sealed class EngineUnavailableException(string message) : Exception(message); + +sealed record PartitionView(string Directory, long? Records); + +sealed record EngineStatus(EngineState State, string Error, DateTime? OpenedAt, string IndexMaintenance, + long EventsAccepted, long EventsProcessed, long EventsDropped, long QueueLength, long Errors, + int OpenPartitions, IReadOnlyList Partitions); + +// Owns the primary analytics engine. Requests hold a lease while they use it; reopening waits +// for the leases to end, closes the engine (which flushes it) and opens it with new options. +sealed class EngineHost(IWebHostEnvironment environment, ILoggerFactory loggers) : IAsyncDisposable +{ + static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(30); + readonly object sync = new(); + readonly SemaphoreSlim transitions = new(1, 1); + readonly ILogger logger = loggers.CreateLogger(); + readonly ILogger engineLogger = loggers.CreateLogger("ZoneTree.Analytics"); + AnalyticsEngine engine; + EngineState state = EngineState.NotConfigured; + string error; + DateTime? openedAt; + int readers; + TaskCompletionSource drained = Completed(); + TaskCompletionSource settled = Completed(); + bool disposed; + + static TaskCompletionSource Completed() + { + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + source.SetResult(); + return source; + } + + public EngineState State { get { lock (sync) return state; } } + + public async Task AcquireAsync(CancellationToken cancellationToken) + { + while (true) + { + Task wait; + lock (sync) + { + switch (state) + { + case EngineState.Running: + if (readers++ == 0) drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + return new EngineLease(engine, Release); + case EngineState.NotConfigured: + throw new EngineUnavailableException("Analytics is not set up yet. Finish the setup wizard."); + case EngineState.Failed: + throw new EngineUnavailableException("The analytics engine could not open. The administrator can see the error in the admin panel."); + default: + wait = settled.Task; + break; + } + } + try + { + await wait.WaitAsync(AcquireTimeout, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + throw new EngineUnavailableException("The analytics engine is applying new settings. Try again shortly."); + } + } + } + + void Release() + { + lock (sync) if (--readers == 0) drained.TrySetResult(); + } + + public async Task OpenAsync(DashboardSettings settings) + { + await transitions.WaitAsync().ConfigureAwait(false); + try + { + Begin(EngineState.Opening); + await TryOpenAsync(settings).ConfigureAwait(false); + } + finally { transitions.Release(); } + } + + // Opens the engine with the next settings. When they fail to open, the previous settings are + // opened again and the error is rethrown. + public async Task ReopenAsync(DashboardSettings next, DashboardSettings previous) + { + await transitions.WaitAsync().ConfigureAwait(false); + try + { + Task wait; + AnalyticsEngine current; + lock (sync) + { + current = engine; + state = engine == null ? EngineState.Opening : EngineState.Reopening; + settled = new(TaskCreationOptions.RunContinuationsAsynchronously); + wait = readers == 0 ? Task.CompletedTask : drained.Task; + } + await wait.ConfigureAwait(false); + if (current != null) + { + await current.DisposeAsync().ConfigureAwait(false); + lock (sync) engine = null; + } + var failure = await TryOpenAsync(next).ConfigureAwait(false); + if (failure == null) return; + if (previous != null) + { + Begin(EngineState.Opening); + await TryOpenAsync(previous).ConfigureAwait(false); + } + throw failure; + } + finally { transitions.Release(); } + } + + void Begin(EngineState opening) + { + lock (sync) + { + state = opening; + settled = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + + async Task TryOpenAsync(DashboardSettings settings) + { + try + { + var options = SettingsMapper.ToOptions(settings, environment.ContentRootPath, engineLogger); + // Opening reads metadata and decides index work; keep it off the request threads. + var opened = await Task.Run(() => new AnalyticsEngine(options)).ConfigureAwait(false); + lock (sync) + { + if (disposed) + { + _ = opened.DisposeAsync(); + return new ObjectDisposedException(nameof(EngineHost)); + } + engine = opened; + state = EngineState.Running; + error = null; + openedAt = DateTime.UtcNow; + settled.TrySetResult(); + } + logger.LogInformation("Analytics engine opened at {Directory}.", options.DataDirectory); + return null; + } + catch (Exception exception) + { + logger.LogError(exception, "The analytics engine could not open."); + lock (sync) + { + state = EngineState.Failed; + error = exception.Message; + settled.TrySetResult(); + } + return exception; + } + } + + public EngineStatus GetStatus(bool includePartitions) + { + AnalyticsEngine current; + EngineState currentState; + string currentError; + DateTime? opened; + lock (sync) { current = engine; currentState = state; currentError = error; opened = openedAt; } + AnalyticsStatistics statistics = null; + try { statistics = current?.GetStatistics(includePartitions); } + catch (ObjectDisposedException) { } + return new(currentState, currentError, opened, statistics?.IndexMaintenance, + statistics?.EventsAccepted ?? 0, statistics?.EventsProcessed ?? 0, statistics?.EventsDropped ?? 0, + statistics?.QueueLength ?? 0, statistics?.Errors ?? 0, statistics?.OpenPartitions ?? 0, + statistics?.Partitions.Select(partition => new PartitionView(partition.Directory, partition.ApproximateRecordCount)).ToList() ?? []); + } + + public async ValueTask DisposeAsync() + { + AnalyticsEngine current; + lock (sync) + { + disposed = true; + current = engine; + engine = null; + state = EngineState.NotConfigured; + settled.TrySetResult(); + } + if (current != null) await current.DisposeAsync().ConfigureAwait(false); + } +} + +sealed class EngineLease(AnalyticsEngine engine, Action release) : IDisposable +{ + Action onRelease = release; + public AnalyticsEngine Engine { get; } = engine; + public void Dispose() => Interlocked.Exchange(ref onRelease, null)?.Invoke(); +} + diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs b/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs new file mode 100644 index 0000000..101641d --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs @@ -0,0 +1,107 @@ +using ZoneTree.Analytics; + +// Gives reporting and tracker requests the engine they read or write. Primary requests lease the +// configured engine; in Development the sample and benchmark APIs lease their own datasets. +sealed class EngineMiddleware(RequestDelegate next, EngineHost engines, SettingsService settings, SiteResolution sites, + IServiceProvider services) +{ + public const string ApiPath = "/_analytics/api"; + public const string EventPath = "/_analytics/event"; + public const string SiteItem = "zt-dashboard-site"; + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path; + // Ad-hoc queries are for analysts; funnels and standard reports are for every role. + if (path.StartsWithSegments("/_analytics") && path.Value.EndsWith("/query", StringComparison.OrdinalIgnoreCase) + && DashboardAuthentication.User(context) is { CanQuery: false }) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new { error = "The query explorer needs the analyst role. Ask the administrator for access." }); + return; + } + if (path.StartsWithSegments(SampleDataStore.ApiPath) || path.StartsWithSegments(BenchmarkReportStore.ApiPath)) + { + await SampleAsync(context, path.StartsWithSegments(BenchmarkReportStore.ApiPath)); + return; + } + var isEvent = path.Equals(EventPath, StringComparison.OrdinalIgnoreCase) && HttpMethods.IsPost(context.Request.Method); + if (!isEvent && !path.StartsWithSegments(ApiPath)) + { + await next(context); + return; + } + // The dashboard configuration describes the host, not the engine. + if (path.StartsWithSegments(ApiPath + "/config")) + { + await next(context); + return; + } + if (isEvent) + { + var current = settings.Current; + var site = current == null ? null : sites.Resolve(context, current); + if (site == null) + { + context.Response.StatusCode = StatusCodes.Status204NoContent; + return; + } + context.Items[SiteItem] = site; + } + EngineLease lease; + try + { + lease = await engines.AcquireAsync(context.RequestAborted); + } + catch (EngineUnavailableException error) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = "5"; + if (!isEvent) await context.Response.WriteAsJsonAsync(new { error = error.Message }); + return; + } + using (lease) + await WithEngineAsync(context, lease.Engine); + } + + // The endpoints resolve the engine from the request services. A wrapper hands out the leased + // engine without owning it: a scoped registration would dispose it with the request scope. + async Task WithEngineAsync(HttpContext context, AnalyticsEngine engine) + { + var original = context.RequestServices; + context.RequestServices = new LeasedEngineServices(original, engine); + try { await next(context); } + finally { context.RequestServices = original; } + } + + async Task SampleAsync(HttpContext context, bool benchmark) + { + if (services.GetService() is not { } samples) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + try + { + using var lease = benchmark + ? services.GetRequiredService().Reports.Acquire() + : samples.Acquire(); + await WithEngineAsync(context, lease.Engine); + } + catch (SampleDataBusyException) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync(new { error = "Sample data is being prepared. Try refreshing shortly." }); + } + catch (BenchmarkDataUnavailableException) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsJsonAsync(new { error = "Run a benchmark to create its report dataset." }); + } + } +} + +sealed class LeasedEngineServices(IServiceProvider original, AnalyticsEngine engine) : IServiceProvider +{ + public object GetService(Type serviceType) => serviceType == typeof(AnalyticsEngine) ? engine : original.GetService(serviceType); +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs b/apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs new file mode 100644 index 0000000..1ab02ba --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs @@ -0,0 +1,53 @@ +// Chooses the site of a tracker beacon from the page that sent it: the Origin header, else the +// Referer, matched against each site's domains. Null means the event is not recorded. +sealed class SiteResolution +{ + sealed record Lookup(DashboardSettings Settings, Dictionary Exact, List<(string Suffix, string Apex, string Site)> Wildcards); + + Lookup lookup; + + public string Resolve(HttpContext context, DashboardSettings settings) + { + var current = Volatile.Read(ref lookup); + if (current == null || !ReferenceEquals(current.Settings, settings)) + Volatile.Write(ref lookup, current = Build(settings)); + var host = SourceHost(context); + if (host == null) return Unmatched(settings); + if (string.Equals(host, context.Request.Host.Value, StringComparison.OrdinalIgnoreCase)) + return settings.Sites.TrackDashboardUsage ? SettingsMapper.DashboardUsageSite : null; + var name = host.Split(':')[0]; + if (current.Exact.TryGetValue(host, out var site) || current.Exact.TryGetValue(name, out site)) return site; + foreach (var (suffix, apex, wildcardSite) in current.Wildcards) + if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) || string.Equals(name, apex, StringComparison.OrdinalIgnoreCase)) + return wildcardSite; + return Unmatched(settings); + } + + static string Unmatched(DashboardSettings settings) => + settings.Sites.UnmatchedTraffic == UnmatchedTraffic.PrimarySite ? settings.Sites.PrimarySite : null; + + static string SourceHost(HttpContext context) + { + foreach (var header in new[] { context.Request.Headers.Origin.ToString(), context.Request.Headers.Referer.ToString() }) + if (!string.IsNullOrEmpty(header) && header != "null" && Uri.TryCreate(header, UriKind.Absolute, out var uri) + && uri.Scheme is "http" or "https") + return uri.IsDefaultPort ? uri.Host : uri.Authority; + return null; + } + + static Lookup Build(DashboardSettings settings) + { + var exact = new Dictionary(StringComparer.OrdinalIgnoreCase); + var wildcards = new List<(string, string, string)>(); + foreach (var site in settings.Sites.Items) + foreach (var domain in site.Domains) + { + // *.example.com covers example.com and every subdomain. + if (domain.StartsWith("*.", StringComparison.Ordinal)) wildcards.Add((domain[1..], domain[2..], site.Id)); + else exact[domain] = site.Id; + } + // The longest suffix wins: *.shop.example.com before *.example.com. + wildcards.Sort((first, second) => second.Item1.Length.CompareTo(first.Item1.Length)); + return new(settings, exact, wildcards); + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Http/DashboardJson.cs b/apps/ZoneTree.Analytics.Dashboard/server/Http/DashboardJson.cs new file mode 100644 index 0000000..d658ca7 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Http/DashboardJson.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +sealed class DashboardRequestException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message) +{ + public int StatusCode { get; } = statusCode; +} + +// JSON bodies and replies of the dashboard's own APIs, with the system store's serializer +// options (camel case, enum names). +static class DashboardJson +{ + const int MaxBodyBytes = 1024 * 1024; + + public static async Task ReadAsync(HttpRequest request) + { + if (request.ContentLength > MaxBodyBytes) + throw new DashboardRequestException("The request is too large.", StatusCodes.Status413PayloadTooLarge); + using var buffer = new MemoryStream(); + var chunk = new byte[16 * 1024]; + int read; + while ((read = await request.Body.ReadAsync(chunk, request.HttpContext.RequestAborted)) > 0) + { + if (buffer.Length + read > MaxBodyBytes) + throw new DashboardRequestException("The request is too large.", StatusCodes.Status413PayloadTooLarge); + buffer.Write(chunk, 0, read); + } + if (buffer.Length == 0) throw new DashboardRequestException("Send a JSON body."); + try + { + return JsonSerializer.Deserialize(buffer.GetBuffer().AsSpan(0, (int)buffer.Length), SystemStore.Json) + ?? throw new DashboardRequestException("Send a JSON body."); + } + catch (JsonException error) + { + throw new DashboardRequestException($"The JSON body is invalid: {error.Message}"); + } + } + + public static IResult Ok(object value) => Results.Json(value, SystemStore.Json); + + public static IResult Error(string message, int statusCode = StatusCodes.Status400BadRequest, object extra = null) => + Results.Json(extra ?? new { error = message }, SystemStore.Json, statusCode: statusCode); + + // Turns the dashboard's expected failures into JSON errors and marks replies as private. + public static RouteGroupBuilder WithDashboardErrors(this RouteGroupBuilder group) + { + group.AddEndpointFilter(async (context, next) => + { + context.HttpContext.Response.Headers.CacheControl = "no-store"; + try + { + return await next(context); + } + catch (DashboardRequestException error) { return Error(error.Message, error.StatusCode); } + catch (UserStoreException error) { return Error(error.Message); } + catch (SettingsValidationException error) { return Error(error.Message, extra: new { error = error.Message, key = error.Key }); } + catch (SettingsConfirmationRequiredException error) + { + return Error(error.Message, StatusCodes.Status409Conflict, new { error = error.Message, warnings = error.Warnings }); + } + }); + return group; + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Program.cs b/apps/ZoneTree.Analytics.Dashboard/server/Program.cs index 12950da..f83c593 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Program.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Program.cs @@ -12,81 +12,115 @@ if (!watchDashboard && !File.Exists(Path.Combine(dashboardPath, "index.html"))) throw new InvalidOperationException("Build the dashboard first: cd apps/ZoneTree.Analytics.Dashboard/ui; yarn install; yarn publish. " + "Or supply --Dashboard:Path=."); -var timeZone = TimeZoneInfo.FindSystemTimeZoneById(builder.Configuration["Analytics:TimeZone"] ?? "UTC"); -builder.Services.AddSingleton(timeZone); -var sites = builder.Configuration.GetSection("Dashboard:Sites").Get() - ?? [new("", "Default site")]; + +// Users, sessions, settings and the audit log. Everything else is configured in the dashboard. +var systemDirectory = builder.Configuration["Dashboard:SystemDirectory"] ?? SystemStore.DefaultDirectory; +var systemStore = new SystemStore(Path.IsPathRooted(systemDirectory) ? systemDirectory : Path.Combine(contentRoot, systemDirectory)); +builder.Services.AddSingleton(systemStore); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddDashboardAuthentication(); +builder.Services.AddZoneTreeAnalyticsWeb(web => +{ + web.MaxConcurrentReportingRequests = SettingsService.StoredConcurrentRequests(systemStore); + web.SiteResolver = context => context.Items[EngineMiddleware.SiteItem] as string; + web.AuthorizeSite = (context, site) => DashboardAuthentication.User(context)?.CanAccess(site) == true; +}); + var sampleDataEnabled = builder.Environment.IsDevelopment(); if (sampleDataEnabled) { + // The Development datasets follow the reporting time zone chosen during setup. + builder.Services.AddSingleton(services => TimeZoneInfo.FindSystemTimeZoneById( + services.GetRequiredService().Current?.General.TimeZone ?? "UTC")); builder.Services.AddSingleton(); builder.Services.AddSingleton(services => new SampleBenchmark(contentRoot, - timeZone, services.GetRequiredService>())); + services.GetRequiredService(), services.GetRequiredService>())); builder.Services.AddSingleton(); builder.Services.AddHostedService(services => services.GetRequiredService()); - sites = [.. sites.Where(site => site.Id != SampleDataStore.Site && site.Id != BenchmarkReportStore.Site), - new(SampleDataStore.Site, "Performance samples"), new(BenchmarkReportStore.Site, "Latest benchmark")]; } -var refreshSeconds = builder.Configuration.GetValue("Dashboard:RefreshSeconds", 30); -const string apiPath = "/_analytics/api"; if (watchDashboard) { builder.Services.AddSingleton(); builder.Services.AddHostedService(services => services.GetRequiredService()); } -builder.Services.AddZoneTreeAnalytics(options => DashboardAnalyticsOptions.ConfigurePrimary(options, - builder.Configuration["Analytics:DataDirectory"] ?? DashboardAnalyticsOptions.DefaultDataDirectory, timeZone)); - var app = builder.Build(); -// Open the engine now: data it cannot load fails startup instead of every request. -var engine = app.Services.GetRequiredService(); -if (builder.Configuration.GetValue("Analytics:SeedDemo")) +var settings = app.Services.GetRequiredService(); +var engines = app.Services.GetRequiredService(); +var setup = app.Services.GetRequiredService(); +// Open the engine now: data it cannot load is reported at startup and in the admin panel. +if (settings.IsConfigured) +{ + await engines.OpenAsync(settings.Current); + if (engines.State == EngineState.Running && builder.Configuration.GetValue("Analytics:SeedDemo")) + { + using var lease = await engines.AcquireAsync(CancellationToken.None); + if (lease.Engine.GetEventNames(DateRange.LastDays(60)).Count == 0) + await SampleTraffic.SeedAsync(lease.Engine); + } +} + +app.UseAuthentication(); +app.UseMiddleware(); +app.UseMiddleware(); +using var files = watchDashboard ? null : new PhysicalFileProvider(dashboardPath); +if (!watchDashboard) { - if (engine.GetEventNames(DateRange.LastDays(60)).Count == 0) - await SampleTraffic.SeedAsync(engine); + app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files }); + app.UseStaticFiles(new StaticFileOptions { FileProvider = files }); } +app.UseAuthorization(); +app.UseMiddleware(); -// In an existing production host, put your authorization policy on both -// reporting routes and dashboard access. -var reportingApi = app.MapZoneTreeAnalyticsApi(apiPath); +var reportingApi = app.MapZoneTreeAnalyticsApi(EngineMiddleware.ApiPath).RequireAuthorization(); app.MapZoneTreeAnalytics(); -if (sampleDataEnabled) +app.MapDashboardAuth(); +app.MapDashboardSetup(); +app.MapDashboardAdmin(); +reportingApi.MapGet("/config", (HttpContext context) => { - app.Use(async (context, next) => + context.Response.Headers.CacheControl = "no-store"; + var user = DashboardAuthentication.User(context); + var current = settings.Current; + var sites = current?.Sites.Items.Where(site => user.CanAccess(site.Id)).Select(site => new SiteView(site.Id, site.Name)).ToList() ?? []; + if (user.IsAdministrator && current?.Sites.TrackDashboardUsage == true) + sites.Add(new(SettingsMapper.DashboardUsageSite, "Dashboard usage")); + var samples = sampleDataEnabled && user.IsAdministrator; + if (samples) + sites.AddRange([new(SampleDataStore.Site, "Performance samples"), new(BenchmarkReportStore.Site, "Latest benchmark")]); + return Results.Json(new { - var isBenchmark = context.Request.Path.StartsWithSegments(BenchmarkReportStore.ApiPath); - if (!isBenchmark && !context.Request.Path.StartsWithSegments(SampleDataStore.ApiPath)) { await next(context); return; } - var original = context.RequestServices; - try - { - using var lease = isBenchmark - ? app.Services.GetRequiredService().Reports.Acquire() - : app.Services.GetRequiredService().Acquire(); - context.RequestServices = new SampleRequestServices(original, lease.Engine); - try { await next(context); } - finally { context.RequestServices = original; } - } - catch (SampleDataBusyException) - { - context.Response.StatusCode = 503; - await context.Response.WriteAsJsonAsync(new { error = "Sample data is being prepared. Try refreshing shortly." }); - } - catch (BenchmarkDataUnavailableException) + mode = "live", + apiBaseUrl = EngineMiddleware.ApiPath, + timeZone = current?.General.TimeZone ?? "UTC", + sites, + refreshSeconds = current?.General.RefreshSeconds ?? 30, + sampleData = samples ? new { - context.Response.StatusCode = 404; - await context.Response.WriteAsJsonAsync(new { error = "Run a benchmark to create its report dataset." }); - } + siteId = SampleDataStore.Site, + apiBaseUrl = SampleDataStore.ApiPath, + managementUrl = "/_analytics/sample-data", + benchmark = new { siteId = BenchmarkReportStore.Site, apiBaseUrl = BenchmarkReportStore.ApiPath } + } : null, + user = new { id = user.Id, userName = user.UserName, displayName = user.DisplayName, role = user.Role.ToString(), canQuery = user.CanQuery }, }); - app.MapZoneTreeAnalyticsApi(SampleDataStore.ApiPath); - app.MapZoneTreeAnalyticsApi(BenchmarkReportStore.ApiPath); - var management = app.MapGroup("/_analytics/sample-data"); +}); +if (sampleDataEnabled) +{ + app.MapZoneTreeAnalyticsApi(SampleDataStore.ApiPath).RequireAuthorization(DashboardAuthentication.AdministratorPolicy); + app.MapZoneTreeAnalyticsApi(BenchmarkReportStore.ApiPath).RequireAuthorization(DashboardAuthentication.AdministratorPolicy); + var management = app.MapGroup("/_analytics/sample-data").RequireAuthorization(DashboardAuthentication.AdministratorPolicy); management.AddEndpointFilter(async (context, next) => { - var request = context.HttpContext.Request; - if (!HttpMethods.IsGet(request.Method) && request.Headers.Origin.Count > 0 - && request.Headers.Origin.ToString() != $"{request.Scheme}://{request.Host}") - return Results.Json(new { error = "Use sample controls from this host." }, statusCode: 403); context.HttpContext.Response.Headers.CacheControl = "no-store"; return await next(context); }); @@ -107,54 +141,37 @@ management.MapPost("/clear-benchmark", (System.Text.Json.JsonElement body, SampleDataJobs jobs) => jobs.ClearBenchmark() ? Results.Json(jobs.Status, statusCode: 202) : Results.Conflict(new { error = "Cancel the running operation before deleting benchmark data." })); } -reportingApi.MapGet("/config", (HttpContext context) => -{ - context.Response.Headers.CacheControl = "no-store"; - return Results.Json(new - { - mode = "live", - apiBaseUrl = apiPath, - timeZone = timeZone.Id, - sites, - refreshSeconds, - sampleData = sampleDataEnabled ? new - { - siteId = SampleDataStore.Site, - apiBaseUrl = SampleDataStore.ApiPath, - managementUrl = "/_analytics/sample-data", - benchmark = new { siteId = BenchmarkReportStore.Site, apiBaseUrl = BenchmarkReportStore.ApiPath } - } : null, - }); -}); -using var files = watchDashboard ? null : new PhysicalFileProvider(dashboardPath); if (watchDashboard) app.MapFallback("/{**path}", app.Services.GetRequiredService().ForwardAsync); -else -{ - app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files }); - app.UseStaticFiles(new StaticFileOptions { FileProvider = files }); -} -// Complete migrations before browsers start their reporting request timeouts. +// Complete migrations before browsers start their reporting request timeouts. A Development +// dataset that cannot open is reported on its pages; it does not stop the dashboard. if (sampleDataEnabled) { - var samples = app.Services.GetRequiredService(); - if (samples.HasRetainedData) - using (samples.Acquire()) { } - var benchmark = app.Services.GetRequiredService(); - if (benchmark.Reports.Result != null) - using (benchmark.Reports.Acquire()) { } + try + { + var samples = app.Services.GetRequiredService(); + if (samples.HasRetainedData) + using (samples.Acquire()) { } + var benchmark = app.Services.GetRequiredService(); + if (benchmark.Reports.Result != null) + using (benchmark.Reports.Acquire()) { } + } + catch (Exception error) when (error is AnalyticsConfigurationException or IOException) + { + app.Logger.LogError(error, "A Development sample dataset could not open. Delete it from the Sample data page to start a new one."); + } } app.Lifetime.ApplicationStarted.Register(() => { - foreach (var address in app.Urls) - { - var displayAddress = address; - if (Uri.TryCreate(address, UriKind.Absolute, out var uri) - && uri.Host is "0.0.0.0" or "[::]") - displayAddress = new UriBuilder(uri) { Host = "localhost" }.Uri.AbsoluteUri; - app.Logger.LogInformation("Open dashboard: {Url}", displayAddress); - } + var displayAddresses = app.Urls.Select(address => + Uri.TryCreate(address, UriKind.Absolute, out var uri) && uri.Host is "0.0.0.0" or "[::]" + ? new UriBuilder(uri) { Host = "localhost" }.Uri.AbsoluteUri + : address).ToList(); + foreach (var address in displayAddresses) + app.Logger.LogInformation("Open dashboard: {Url}", address); + if (!setup.Complete && displayAddresses.Count > 0) + _ = SetupBanner.ShowAsync(displayAddresses[0], app.Services.GetRequiredService(), setup, + watchDashboard ? app.Services.GetRequiredService() : null, app.Lifetime.ApplicationStopping); }); +app.Lifetime.ApplicationStopped.Register(() => systemStore.Dispose()); await app.RunAsync(); - -sealed record DashboardSite(string Id, string Name); diff --git a/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs b/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs index 2b73db4..de3c585 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs @@ -2,7 +2,7 @@ // This engine owns only generated data. Reporting leases protect it while a // reset closes the engine and removes all raw events, rollups and metadata. -sealed class SampleDataStore(IWebHostEnvironment environment, IConfiguration configuration, TimeZoneInfo timeZone, +sealed class SampleDataStore(IWebHostEnvironment environment, SettingsService settings, TimeZoneInfo timeZone, ILogger logger) : IAsyncDisposable { public const string Site = DashboardAnalyticsOptions.PerformanceSite; @@ -15,6 +15,7 @@ sealed class SampleDataStore(IWebHostEnvironment environment, IConfiguration con bool resetting; TaskCompletionSource drained = new(TaskCreationOptions.RunContinuationsAsynchronously); const string Marker = ".zonetree-generated-samples"; + const string TimeZoneFile = "time-zone.txt"; public bool HasRetainedData => File.Exists(Path.Combine(directory, Marker)); @@ -35,7 +36,18 @@ public SampleEngineLease Acquire() AnalyticsEngine Open() { EnsureOwnedDirectory(); - return new AnalyticsEngine(DashboardAnalyticsOptions.CreatePerformanceSamples(directory, timeZone, workerCount, logger)); + return new AnalyticsEngine(DashboardAnalyticsOptions.CreatePerformanceSamples(directory, DatasetTimeZone(), workerCount, logger)); + } + + // A dataset keeps the reporting time zone it was created with; a new one takes the dashboard's. + // Datasets that predate this record were created in UTC, the dashboard's earlier default. + TimeZoneInfo DatasetTimeZone() + { + var path = Path.Combine(directory, TimeZoneFile); + if (File.Exists(path)) return TimeZoneInfo.FindSystemTimeZoneById(File.ReadAllText(path).Trim()); + var zone = Directory.Exists(Path.Combine(directory, "meta")) ? TimeZoneInfo.Utc : timeZone; + File.WriteAllText(path, zone.Id); + return zone; } public async Task PrepareGenerationAsync(int workers, CancellationToken cancellationToken) @@ -63,7 +75,7 @@ public async Task PrepareGenerationAsync(int workers, CancellationToken cancella void EnsureOwnedDirectory() { - var primary = configuration["Analytics:DataDirectory"] ?? DashboardAnalyticsOptions.DefaultDataDirectory; + var primary = settings.Current == null ? DashboardAnalyticsOptions.DefaultDataDirectory : settings.Current.General.DataDirectory; if (!Path.IsPathRooted(primary)) primary = Path.Combine(environment.ContentRootPath, primary); primary = Path.TrimEndingDirectorySeparator(Path.GetFullPath(primary)); if (string.Equals(primary, directory, StringComparison.OrdinalIgnoreCase) diff --git a/apps/ZoneTree.Analytics.Dashboard/server/SampleEngineLease.cs b/apps/ZoneTree.Analytics.Dashboard/server/SampleEngineLease.cs index be4701c..ed48436 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/SampleEngineLease.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/SampleEngineLease.cs @@ -9,8 +9,3 @@ sealed class SampleEngineLease(AnalyticsEngine engine, Action release) : IDispos sealed class SampleDataBusyException : Exception; sealed class BenchmarkDataUnavailableException : Exception; - -sealed class SampleRequestServices(IServiceProvider original, AnalyticsEngine engine) : IServiceProvider -{ - public object GetService(Type serviceType) => serviceType == typeof(AnalyticsEngine) ? engine : original.GetService(serviceType); -} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/DashboardSettings.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/DashboardSettings.cs new file mode 100644 index 0000000..fdb3ce9 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/DashboardSettings.cs @@ -0,0 +1,415 @@ +using ZoneTree.Analytics; +using ZoneTree.Options; + +// The persisted dashboard configuration. Every serializable engine and host option is here; +// extensions that are code (geolocator, user agent parser, filters, enrichers) stay in code. +// The [Setting] metadata drives the setup wizard and the admin forms (see SettingsSchema). +sealed class DashboardSettings +{ + [Section("General", Help = "Calendar, storage location and dashboard behavior.")] + public GeneralSettings General { get; set; } = new(); + + [Section("Sites", Help = "The websites this server collects analytics for.")] + public SitesSettings Sites { get; set; } = new(); + + [Section("Tracking", Help = "How page views and events are collected and cleaned.")] + public TrackingSettings Tracking { get; set; } = new(); + + [Section("Privacy", Help = "Visitor identification and data minimization.")] + public PrivacySettings Privacy { get; set; } = new(); + + [Section("Sessions", Help = "Visits, bounce rate, duration, entry and exit pages.")] + public SessionSettings Sessions { get; set; } = new(); + + [Section("Retention", Help = "How long each level of detail is kept. Older data is deleted.")] + public RetentionSettings Retention { get; set; } = new(); + + [Section("Rollups", Help = "Pre-aggregated reports that answer queries without scanning raw events.")] + public RollupSettings Rollups { get; set; } = new(); + + [Section("Indexes", Help = "Secondary indexes that speed up filtered reports.")] + public IndexSettings Indexes { get; set; } = new(); + + [Section("Ingestion", Help = "Event queue, workers and flushing.")] + public IngestionSettings Ingestion { get; set; } = new(); + + [Section("Queries", Help = "Limits that protect the server from expensive reports.")] + public QuerySettings Queries { get; set; } = new(); + + [Section("Storage", Help = "Raw event storage, partitions and ZoneTree store tuning.")] + public StorageSettings Storage { get; set; } = new(); + + [Section("Reporting API", Help = "Request and response limits of the reporting API.")] + public ReportingSettings Reporting { get; set; } = new(); + + [Section("Security", Help = "Dashboard sign-in sessions and sign-in protection.")] + public SecuritySettings Security { get; set; } = new(); +} + +static class CoreDefaults +{ + public static readonly AnalyticsOptions Options = new(); +} + +sealed class GeneralSettings +{ + [Setting("Reporting time zone", Tier = SettingTier.Fixed, Editor = "timeZone", + Help = "Days, weeks and months follow this IANA time zone. It is stored with the data and cannot change later.")] + public string TimeZone { get; set; } = "UTC"; + + [Setting("First day of the week", Help = "Where weekly reports start.")] + public DayOfWeek FirstDayOfWeek { get; set; } = CoreDefaults.Options.FirstDayOfWeek; + + [Setting("Data directory", Tier = SettingTier.Fixed, Advanced = true, + Help = "Where analytics data is stored. A relative path is resolved against the server's content root.")] + public string DataDirectory { get; set; } = "data/analytics"; + + [Setting("Auto-refresh", Tier = SettingTier.Live, Options = "0:Off|30:Every 30 seconds|60:Every minute|300:Every 5 minutes", + Help = "How often open reports reload.")] + public int RefreshSeconds { get; set; } = 30; +} + +enum UnmatchedTraffic { PrimarySite, Ignore } + +sealed class SiteSettings +{ + public string Id { get; set; } + public string Name { get; set; } + // Host names whose pages report to this site: exact, or *.example.com for subdomains. + public List Domains { get; set; } = []; +} + +sealed class SitesSettings +{ + [Setting("Sites", Tier = SettingTier.Live, Editor = "sites")] + public List Items { get; set; } = []; + + [Setting("Primary site", Editor = "primarySite", + Help = "Receives events that name no site and answers reports that name none.")] + public string PrimarySite { get; set; } + + [Setting("Traffic from other domains", Tier = SettingTier.Live, + Help = "What happens to events from pages whose host matches no site's domains.")] + public UnmatchedTraffic UnmatchedTraffic { get; set; } = UnmatchedTraffic.PrimarySite; + + [Setting("Record dashboard usage", Tier = SettingTier.Live, + Help = "Track visits to this dashboard in a separate \"Dashboard usage\" site that only the administrator sees.")] + public bool TrackDashboardUsage { get; set; } +} + +sealed class TrackingSettings +{ + [Setting("Bot traffic", Help = "Discard bots, or keep them in separate bot reports.")] + public BotTraffic Bots { get; set; } = BotTraffic.TrackSeparately; + + [Setting("Remove trailing slashes", Help = "Report /pricing/ and /pricing as one page.")] + public bool TrimTrailingSlash { get; set; } = CoreDefaults.Options.Web.TrimTrailingSlash; + + [Setting("Lowercase page paths", Help = "Report /Pricing and /pricing as one page.")] + public bool LowercasePaths { get; set; } = CoreDefaults.Options.Web.LowercasePaths; + + [Setting("Allowed custom events", Tier = SettingTier.Live, Editor = "tags", + Help = "Event names the browser tracker may send. Empty accepts every name.")] + public List AllowedClientEvents { get; set; } = []; + + [Setting("Internal hosts", Advanced = true, Editor = "tags", + Help = "Referrers from these hosts count as direct traffic, like referrers from the page's own host.")] + public List InternalHosts { get; set; } = []; + + [Setting("Referrer sources", Advanced = true, Editor = "map", KeyLabel = "Referrer host", ValueLabel = "Source name", + Help = "Extra referrer host to source mappings, for example news.ycombinator.com → Hacker News.")] + public Dictionary ReferrerSources { get; set; } = []; + + [Setting("Maximum event size", Tier = SettingTier.Live, Advanced = true, Unit = "bytes", Min = 256, Max = 1048576, + Help = "Larger tracker requests are rejected.")] + public int MaxPayloadBytes { get; set; } = 8 * 1024; +} + +sealed class PrivacySettings +{ + [Setting("Visitor identification", + Help = "Daily rotating hash needs no cookies and cannot follow visitors across days. Persistent hash recognizes returning visitors.")] + public VisitorIdentification VisitorIdentification { get; set; } = CoreDefaults.Options.Privacy.VisitorIdentification; + + [Setting("Location precision", Help = "The most precise location kept, when a geolocator is configured.")] + public GeoPrecision GeoPrecision { get; set; } = CoreDefaults.Options.Privacy.GeoPrecision; + + [Setting("Browser, OS and device", Help = "Derive browser, operating system and device type from the user agent.")] + public bool CollectUserAgentDetails { get; set; } = CoreDefaults.Options.Privacy.CollectUserAgentDetails; + + [Setting("Browser and OS versions", Help = "Also keep version numbers.")] + public bool CollectVersions { get; set; } = CoreDefaults.Options.Privacy.CollectVersions; + + [Setting("Keep query strings", Help = "Keep ?query parameters in page URLs. They can contain personal data.")] + public bool IncludeQueryString { get; set; } = CoreDefaults.Options.Privacy.IncludeQueryString; + + [Setting("Allow only listed custom dimensions", Advanced = true, + Help = "When on, custom dimensions not listed below are discarded.")] + public bool RestrictDimensions { get; set; } + + [Setting("Allowed custom dimensions", Advanced = true, Editor = "tags")] + public List AllowedDimensions { get; set; } = []; + + [Setting("Denied custom dimensions", Advanced = true, Editor = "tags", Help = "Always discarded.")] + public List DeniedDimensions { get; set; } = []; + + [Setting("Custom dimensions per event", Advanced = true, Min = 0, Max = 1000)] + public int MaxCustomDimensions { get; set; } = CoreDefaults.Options.Privacy.MaxCustomDimensions; + + [Setting("Custom values per event", Advanced = true, Min = 0, Max = 1000)] + public int MaxCustomValues { get; set; } = CoreDefaults.Options.Privacy.MaxCustomValues; + + [Setting("Maximum value length", Advanced = true, Unit = "characters", Min = 16, Max = 65536)] + public int MaxValueLength { get; set; } = CoreDefaults.Options.Privacy.MaxValueLength; +} + +sealed class SessionSettings +{ + [Setting("Track sessions", Help = "Required for sessions, bounce rate, visit duration, entry and exit pages.")] + public bool Enabled { get; set; } = CoreDefaults.Options.Sessions.Enabled; + + [Setting("Session timeout", Unit = "minutes", Min = 1, Max = 1440, Help = "Inactivity that ends a visit.")] + public int TimeoutMinutes { get; set; } = (int)CoreDefaults.Options.Sessions.Timeout.TotalMinutes; + + [Setting("Custom session dimensions", Advanced = true, Editor = "tags", + Help = "Custom dimensions captured from a visit's first event, for example an experiment variant.")] + public List Dimensions { get; set; } = []; + + [Setting("Cached sessions per worker", Advanced = true, Min = 1, Max = 100000000)] + public int MaxCachedSessions { get; set; } = CoreDefaults.Options.Sessions.MaxCachedSessions; +} + +sealed class RetentionSettings +{ + [Setting("Raw events", Unit = "days", Min = 1, Max = 36500, EmptyLabel = "Keep forever", + Help = "Raw events answer ad-hoc queries, real-time counts and erasure requests.")] + public int? RawEventsDays { get; set; } = 365; + + [Setting("Minute rollups", Unit = "days", Min = 1, Max = 36500, EmptyLabel = "Keep forever")] + public int? MinuteRollupsDays { get; set; } = 2; + + [Setting("Hourly rollups", Unit = "days", Min = 1, Max = 36500, EmptyLabel = "Keep forever")] + public int? HourRollupsDays { get; set; } = 400; + + [Setting("Daily rollups", Unit = "days", Min = 1, Max = 36500, EmptyLabel = "Keep forever", + Help = "Daily rollups answer week, month and year reports.")] + public int? DayRollupsDays { get; set; } + + [Setting("Retention check interval", Advanced = true, Unit = "minutes", Min = 1, Max = 10080, EmptyLabel = "Never (manual)")] + public int? CheckIntervalMinutes { get; set; } = 60; +} + +enum RollupGranularity { Minute, Hour, Day } + +sealed class RollupDefinitionSettings +{ + public List Dimensions { get; set; } = []; + // Null counts every event. + public List Events { get; set; } +} + +sealed class ValueAggregateSettings +{ + public string Value { get; set; } + public bool Percentiles { get; set; } + public bool FirstAndLast { get; set; } +} + +sealed class RollupSettings +{ + [Setting("Granularities", Editor = "multi", Help = "Week, month and year reports are computed from daily rollups.")] + public List Granularities { get; set; } = [RollupGranularity.Hour, RollupGranularity.Day]; + + [Setting("Rollup definitions", Editor = "rollups")] + public List Definitions { get; set; } = + [.. RollupDefinition.WebDefaults().Select(definition => new RollupDefinitionSettings + { + Dimensions = [.. definition.Dimensions], + Events = definition.Events?.ToList(), + })]; + + [Setting("Value aggregates", Editor = "valueAggregates")] + public List ValueAggregates { get; set; } = [new() { Value = "load_time", Percentiles = true }]; + + [Setting("Distinct-count dimensions", Advanced = true, Editor = "tags", + Help = "Dimensions whose distinct values are counted in every rollup row. Each costs a sketch per row.")] + public List DistinctDimensions { get; set; } = []; + + [Setting("Visitor sketch precision", Tier = SettingTier.Fixed, Advanced = true, Min = 8, Max = 18, + Help = "Unique visitor counts are exact up to 2^precision / 4 per row. Stored with the data and cannot change later.")] + public int HyperLogLogPrecision { get; set; } = CoreDefaults.Options.Rollups.HyperLogLogPrecision; +} + +sealed class RollupIndexSettings +{ + public string Dimension { get; set; } + public List GroupDimensions { get; set; } = []; +} + +sealed class IndexSettings +{ + [Setting("Dimension indexes", Editor = "dimensionIndexes")] + public List Dimensions { get; set; } = [.. DashboardIndexPreset.Dimensions]; + + [Setting("Rollup indexes", Editor = "rollupIndexes")] + public List Rollups { get; set; } = DashboardIndexPreset.RollupIndexes(); + + [Setting("Dimension index partition size", Advanced = true, Unit = "records", Min = 1000, Max = 1000000000)] + public long DimensionRecordsPerPartition { get; set; } = CoreDefaults.Options.Storage.DimensionIndexes.RecordsPerPartition; + + [Setting("Rollup index partition size", Advanced = true, Unit = "records", Min = 1000, Max = 1000000000)] + public long RollupRecordsPerPartition { get; set; } = CoreDefaults.Options.Storage.RollupIndexes.RecordsPerPartition; +} + +// The overview's filters, covered in both directions for every pair. +static class DashboardIndexPreset +{ + public static readonly string[] Dimensions = ["source", "country", "device", "page"]; + + public static List RollupIndexes() => + [.. from dimension in Dimensions + from other in Dimensions + where dimension != other + select new RollupIndexSettings { Dimension = dimension, GroupDimensions = [other] }]; +} + +sealed class IngestionSettings +{ + [Setting("Ingestion workers", Advanced = true, Min = 1, Max = 64)] + public int WorkerCount { get; set; } = CoreDefaults.Options.Ingestion.WorkerCount; + + [Setting("Rollup writers", Advanced = true, Min = 1, Max = 64)] + public int RollupWriterCount { get; set; } = CoreDefaults.Options.Ingestion.RollupWriterCount; + + [Setting("Queue capacity", Advanced = true, Unit = "events", Min = 1, Max = 100000000)] + public int QueueCapacity { get; set; } = CoreDefaults.Options.Ingestion.QueueCapacity; + + [Setting("When the queue is full", Advanced = true, Help = "Drop new events, or make trackers wait.")] + public QueueFullBehavior QueueFullBehavior { get; set; } = CoreDefaults.Options.Ingestion.QueueFullBehavior; + + [Setting("Flush interval", Advanced = true, Unit = "seconds", Min = 0.1, Max = 3600)] + public double FlushIntervalSeconds { get; set; } = CoreDefaults.Options.Ingestion.FlushInterval.TotalSeconds; + + [Setting("Buffered rollup rows", Advanced = true, Unit = "rows", Min = 1, Max = 100000000)] + public int MaxBufferedRollupRows { get; set; } = CoreDefaults.Options.Ingestion.MaxBufferedRollupRows; + + [Setting("Accepted clock skew", Advanced = true, Unit = "minutes", Min = 0, Max = 10080, + Help = "Events timestamped further in the future are rejected.")] + public int MaxFutureSkewMinutes { get; set; } = (int)CoreDefaults.Options.Ingestion.MaxFutureSkew.TotalMinutes; +} + +sealed class QuerySettings +{ + [Setting("Query timeout", Unit = "seconds", Min = 1, Max = 3600, EmptyLabel = "No limit")] + public int? TimeoutSeconds { get; set; } = 30; + + [Setting("Parallel scan threads", Advanced = true, Min = 0, Max = 1024, Help = "0 uses one per processor.")] + public int MaxDegreeOfParallelism { get; set; } + + [Setting("Group-by dimensions", Advanced = true, Min = 1, Max = 64)] + public int MaxGroupBy { get; set; } = CoreDefaults.Options.Queries.MaxGroupBy; + + [Setting("Groups per query", Advanced = true, Min = 1, Max = 100000000)] + public int MaxGroups { get; set; } = CoreDefaults.Options.Queries.MaxGroups; + + [Setting("Time buckets per query", Advanced = true, Min = 1, Max = 100000000)] + public int MaxTimeBuckets { get; set; } = CoreDefaults.Options.Queries.MaxTimeBuckets; + + [Setting("Filled rows per query", Advanced = true, Min = 1, Max = 1000000000)] + public int MaxFilledRows { get; set; } = CoreDefaults.Options.Queries.MaxFilledRows; +} + +// The ZoneTree settings an administrator may tune. The B+tree lock mode, B+tree node sizes and +// Bloom filters are not settings: they keep the engine's defaults. +sealed class StoreSettings +{ + [Setting("Write-ahead log", Help = "Async compressed is ZoneTree's default.")] + public WriteAheadLogMode WriteAheadLogMode { get; set; } = WriteAheadLogMode.AsyncCompressed; + + [Setting("Mutable segment size", Unit = "records", Min = 1000, Max = 100000000, EmptyLabel = "ZoneTree default")] + public int? MutableSegmentMaxItemCount { get; set; } + + [Setting("Disk segment size", Unit = "records", Min = 10000, Max = 1000000000, EmptyLabel = "ZoneTree default")] + public int? DiskSegmentMaxItemCount { get; set; } + + [Setting("Single segment garbage collection", EmptyLabel = "ZoneTree default")] + public bool? EnableSingleSegmentGarbageCollection { get; set; } + + public static StoreSettings From(StoreOptions store) => new() + { + WriteAheadLogMode = store.WriteAheadLogMode, + MutableSegmentMaxItemCount = store.MutableSegmentMaxItemCount, + DiskSegmentMaxItemCount = store.DiskSegmentMaxItemCount, + EnableSingleSegmentGarbageCollection = store.EnableSingleSegmentGarbageCollection, + }; +} + +sealed class StorageSettings +{ + [Setting("Store raw events", Help = "Without raw events only rollups are kept: ad-hoc queries, dimension indexes and real-time counts are unavailable.")] + public bool StoreRawEvents { get; set; } = CoreDefaults.Options.Storage.StoreRawEvents; + + [Setting("Write-ahead log directory", Tier = SettingTier.Fixed, Advanced = true, EmptyLabel = "Next to the data", + Help = "A separate directory for write-ahead logs, for example on a faster disk. Cannot change later.")] + public string WriteAheadLogDirectory { get; set; } + + [Setting("Close idle partitions after", Advanced = true, Unit = "minutes", Min = 1, Max = 10080)] + public int PartitionIdleTimeoutMinutes { get; set; } = (int)CoreDefaults.Options.Storage.PartitionIdleTimeout.TotalMinutes; + + [Setting("Raw event partition size", Advanced = true, Unit = "records", Min = 1000, Max = 1000000000)] + public long RawEventsPerPartition { get; set; } = CoreDefaults.Options.Storage.Partitioning.RawEvents; + + [Setting("Minute rollup partition size", Advanced = true, Unit = "records", Min = 1000, Max = 1000000000)] + public long MinuteRollupsPerPartition { get; set; } = CoreDefaults.Options.Storage.Partitioning.MinuteRollups; + + [Setting("Hourly rollup partition size", Advanced = true, Unit = "records", Min = 1000, Max = 1000000000)] + public long HourRollupsPerPartition { get; set; } = CoreDefaults.Options.Storage.Partitioning.HourRollups; + + [Setting("Daily rollup partition size", Advanced = true, Unit = "records", Min = 1000, Max = 1000000000)] + public long DayRollupsPerPartition { get; set; } = CoreDefaults.Options.Storage.Partitioning.DayRollups; + + [Setting("Raw event store", Advanced = true)] + public StoreSettings RawEventStore { get; set; } = StoreSettings.From(CoreDefaults.Options.Storage.RawEvents); + + [Setting("Rollup store", Advanced = true)] + public StoreSettings RollupStore { get; set; } = StoreSettings.From(CoreDefaults.Options.Storage.Rollups); + + [Setting("Session store", Advanced = true)] + public StoreSettings SessionStore { get; set; } = StoreSettings.From(CoreDefaults.Options.Storage.Sessions); + + [Setting("Dimension index store", Advanced = true)] + public StoreSettings DimensionIndexStore { get; set; } = StoreSettings.From(CoreDefaults.Options.Storage.DimensionIndexes.Store); + + [Setting("Rollup index store", Advanced = true)] + public StoreSettings RollupIndexStore { get; set; } = StoreSettings.From(CoreDefaults.Options.Storage.RollupIndexes.Store); +} + +sealed class ReportingSettings +{ + [Setting("Concurrent reports", Tier = SettingTier.Restart, Advanced = true, Min = 1, Max = 1024, + Help = "Reports running at once. Further requests receive 503.")] + public int MaxConcurrentRequests { get; set; } = 4; + + [Setting("Maximum request size", Tier = SettingTier.Live, Advanced = true, Unit = "bytes", Min = 1024, Max = 16777216)] + public int MaxRequestBytes { get; set; } = 64 * 1024; + + [Setting("Maximum response size", Tier = SettingTier.Live, Advanced = true, Unit = "bytes", Min = 65536, Max = 1073741824)] + public int MaxResponseBytes { get; set; } = 16 * 1024 * 1024; +} + +sealed class SecuritySettings +{ + [Setting("Sign out after inactivity", Tier = SettingTier.Live, Unit = "hours", Min = 1, Max = 8760)] + public int SessionIdleHours { get; set; } = 7 * 24; + + [Setting("Maximum session length", Tier = SettingTier.Live, Unit = "days", Min = 1, Max = 365, + Help = "Users sign in again after this long, even when active.")] + public int SessionLifetimeDays { get; set; } = 30; + + [Setting("Failed sign-ins before a pause", Tier = SettingTier.Live, Min = 1, Max = 100)] + public int MaxFailedSignIns { get; set; } = 5; + + [Setting("Sign-in pause", Tier = SettingTier.Live, Unit = "minutes", Min = 1, Max = 1440, + Help = "How long sign-in is paused for a user name and address after repeated failures.")] + public int SignInLockoutMinutes { get; set; } = 15; +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsDocument.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsDocument.cs new file mode 100644 index 0000000..4f834b3 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsDocument.cs @@ -0,0 +1,46 @@ +using System.Text.Json; + +sealed record SettingChange(string Key, string Label, SettingTier Tier, JsonElement Before, JsonElement After); + +// Reads settings by schema key and compares two settings documents field by field. +static class SettingsDocument +{ + public static object Read(DashboardSettings settings, string key) + { + object current = settings; + foreach (var segment in key.Split('.')) + { + if (current == null) return null; + var property = current.GetType().GetProperties() + .FirstOrDefault(property => SettingsSchema.Camel(property.Name) == segment) + ?? throw new ArgumentException($"Unknown setting '{key}'."); + current = property.GetValue(current); + } + return current; + } + + public static DashboardSettings Clone(DashboardSettings settings) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(settings, SystemStore.Json), SystemStore.Json); + + public static List Diff(DashboardSettings before, DashboardSettings after) + { + var changes = new List(); + foreach (var section in SettingsSchema.Document.Sections) + foreach (var field in section.Fields) + Compare(field, before, after, changes); + return changes; + } + + static void Compare(SettingField field, DashboardSettings before, DashboardSettings after, List changes) + { + if (field.Fields != null) + { + foreach (var child in field.Fields) Compare(child, before, after, changes); + return; + } + var previous = JsonSerializer.SerializeToElement(Read(before, field.Key), SystemStore.Json); + var next = JsonSerializer.SerializeToElement(Read(after, field.Key), SystemStore.Json); + if (previous.GetRawText() != next.GetRawText()) + changes.Add(new(field.Key, field.Label, field.Tier, previous, next)); + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs new file mode 100644 index 0000000..63a834b --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs @@ -0,0 +1,285 @@ +using System.Text.RegularExpressions; +using ZoneTree.Analytics; +using ZoneTree.Analytics.AspNetCore; + +sealed class SettingsValidationException(string message, string key = null) : Exception(message) +{ + public string Key { get; } = key; +} + +static partial class SettingsMapper +{ + // Dashboard visits recorded when Sites.TrackDashboardUsage is on. + public const string DashboardUsageSite = "dashboard-usage"; + public static readonly string[] ReservedSites = [DashboardUsageSite, DashboardAnalyticsOptions.PerformanceSite, DashboardAnalyticsOptions.BenchmarkSite]; + + [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,63}$")] + private static partial Regex SiteIdPattern(); + + [GeneratedRegex(@"^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$")] + private static partial Regex DomainPattern(); + + public static string ResolveDataDirectory(DashboardSettings settings, string contentRoot) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.IsPathRooted(settings.General.DataDirectory) + ? settings.General.DataDirectory + : Path.Combine(contentRoot, settings.General.DataDirectory))); + + // Checks the dashboard's own rules, then the engine's. + public static void Validate(DashboardSettings settings, string contentRoot, string systemDirectory) + { + if (settings == null) throw new SettingsValidationException("Settings are required."); + foreach (var section in typeof(DashboardSettings).GetProperties()) + if (section.GetValue(settings) == null) + throw new SettingsValidationException($"The {SettingsSchema.Camel(section.Name)} section is missing.", SettingsSchema.Camel(section.Name)); + ValidateNumbers(settings); + ValidateGeneral(settings, contentRoot, systemDirectory); + ValidateSites(settings.Sites); + if (settings.Rollups.Granularities.Count == 0) + throw new SettingsValidationException("Choose at least one rollup granularity.", "rollups.granularities"); + if (settings.Indexes.Dimensions.Count > 0 && !settings.Storage.StoreRawEvents) + throw new SettingsValidationException("Dimension indexes copy raw events. Store raw events, or remove the dimension indexes.", "indexes.dimensions"); + try + { + ToOptions(settings, contentRoot, null).Validate(); + } + catch (ArgumentException error) + { + throw new SettingsValidationException(error.Message); + } + } + + // Numbers stay inside their [Setting] ranges; an enum holds one of its names. + static void ValidateNumbers(DashboardSettings settings) + { + foreach (var section in SettingsSchema.Document.Sections) + foreach (var field in section.Fields) + Check(field, settings); + + static void Check(SettingField field, DashboardSettings settings) + { + if (field.Fields != null) + { + foreach (var child in field.Fields) Check(child, settings); + return; + } + var value = SettingsDocument.Read(settings, field.Key); + if (value == null) + { + if (!field.Nullable && field.Type is "number" or "select" or "toggle") + throw new SettingsValidationException($"{field.Label} is required.", field.Key); + return; + } + if (field.Type == "number" && Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture) is var number + && (double.IsNaN(number) || number < (field.Min ?? double.MinValue) || number > (field.Max ?? double.MaxValue))) + throw new SettingsValidationException( + $"{field.Label} must be between {field.Min:0.###} and {field.Max:0.###}{(field.Unit == null ? "" : " " + field.Unit)}.", field.Key); + if (value is Enum && !Enum.IsDefined(value.GetType(), value)) + throw new SettingsValidationException($"{field.Label} is not a valid choice.", field.Key); + if (field.Type == "select" && field.Options != null && !field.Options.Any(option => option.Value.ToString() == value.ToString())) + throw new SettingsValidationException($"{field.Label} is not a valid choice.", field.Key); + } + } + + static void ValidateGeneral(DashboardSettings settings, string contentRoot, string systemDirectory) + { + var general = settings.General; + if (string.IsNullOrWhiteSpace(general.TimeZone) || !IsIanaTimeZone(general.TimeZone)) + throw new SettingsValidationException("Choose an IANA time zone, such as Europe/Berlin or UTC.", "general.timeZone"); + if (string.IsNullOrWhiteSpace(general.DataDirectory)) + throw new SettingsValidationException("Choose a data directory.", "general.dataDirectory"); + var data = ResolveDataDirectory(settings, contentRoot); + if (systemDirectory != null && Overlaps(data, systemDirectory)) + throw new SettingsValidationException("The analytics data directory must be separate from the dashboard's system directory.", "general.dataDirectory"); + if (settings.Storage.WriteAheadLogDirectory is { } wal && string.IsNullOrWhiteSpace(wal)) + settings.Storage.WriteAheadLogDirectory = null; + } + + public static bool IsIanaTimeZone(string id) + { + if (id == "UTC") return true; + try + { + TimeZoneInfo.FindSystemTimeZoneById(id); + } + catch (Exception error) when (error is TimeZoneNotFoundException or InvalidTimeZoneException) + { + return false; + } + return TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out _); + } + + static bool Overlaps(string first, string second) + { + second = Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)); + return string.Equals(first, second, StringComparison.OrdinalIgnoreCase) + || first.StartsWith(second + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || second.StartsWith(first + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + static void ValidateSites(SitesSettings sites) + { + if (sites.Items.Count == 0) + throw new SettingsValidationException("Add at least one site.", "sites.items"); + var ids = new HashSet(StringComparer.Ordinal); + var domains = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var site in sites.Items) + { + if (site == null) throw new SettingsValidationException("A site is empty.", "sites.items"); + site.Id = site.Id?.Trim(); + site.Name = site.Name?.Trim(); + if (string.IsNullOrEmpty(site.Id) || !SiteIdPattern().IsMatch(site.Id)) + throw new SettingsValidationException( + $"Site ID '{site.Id}' must be 1 to 64 lowercase letters, digits, dots, dashes or underscores.", "sites.items"); + if (ReservedSites.Contains(site.Id)) + throw new SettingsValidationException($"The site ID '{site.Id}' is reserved by the dashboard.", "sites.items"); + if (!ids.Add(site.Id)) + throw new SettingsValidationException($"Two sites use the ID '{site.Id}'.", "sites.items"); + if (string.IsNullOrEmpty(site.Name) || site.Name.Length > 100) + throw new SettingsValidationException($"Give site '{site.Id}' a name of at most 100 characters.", "sites.items"); + site.Domains = [.. (site.Domains ?? []).Select(NormalizeDomain).Where(domain => domain.Length > 0).Distinct(StringComparer.Ordinal)]; + foreach (var domain in site.Domains) + { + if (!DomainPattern().IsMatch(domain)) + throw new SettingsValidationException($"'{domain}' is not a host name. Use example.com, or *.example.com for subdomains.", "sites.items"); + if (!domains.TryAdd(domain, site.Id)) + throw new SettingsValidationException($"The domain '{domain}' belongs to two sites.", "sites.items"); + } + } + if (string.IsNullOrEmpty(sites.PrimarySite)) sites.PrimarySite = sites.Items[0].Id; + if (!ids.Contains(sites.PrimarySite)) + throw new SettingsValidationException("The primary site must be one of the sites.", "sites.primarySite"); + } + + // Accepts a pasted URL: https://www.example.com/path → www.example.com. + static string NormalizeDomain(string value) + { + value = (value ?? "").Trim().ToLowerInvariant(); + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + value = uri.IsDefaultPort ? uri.Host : uri.Authority; + return value.TrimEnd('/', '.'); + } + + public static AnalyticsOptions ToOptions(DashboardSettings settings, string contentRoot, ILogger logger) + { + var options = new AnalyticsOptions + { + DataDirectory = ResolveDataDirectory(settings, contentRoot), + DefaultSite = settings.Sites.PrimarySite, + TimeZone = TimeZoneInfo.FindSystemTimeZoneById(settings.General.TimeZone), + FirstDayOfWeek = settings.General.FirstDayOfWeek, + }; + if (logger != null) options.UseLogger(logger); + + var tracking = settings.Tracking; + options.Web.Bots = tracking.Bots; + options.Web.TrimTrailingSlash = tracking.TrimTrailingSlash; + options.Web.LowercasePaths = tracking.LowercasePaths; + foreach (var host in tracking.InternalHosts) options.Web.InternalHosts.Add(host); + foreach (var (host, source) in tracking.ReferrerSources) options.Web.ReferrerSources[host] = source; + + var privacy = settings.Privacy; + options.Privacy.VisitorIdentification = privacy.VisitorIdentification; + options.Privacy.GeoPrecision = privacy.GeoPrecision; + options.Privacy.CollectUserAgentDetails = privacy.CollectUserAgentDetails; + options.Privacy.CollectVersions = privacy.CollectVersions; + options.Privacy.IncludeQueryString = privacy.IncludeQueryString; + options.Privacy.AllowedDimensions = privacy.RestrictDimensions ? new HashSet(privacy.AllowedDimensions, StringComparer.Ordinal) : null; + foreach (var dimension in privacy.DeniedDimensions) options.Privacy.DeniedDimensions.Add(dimension); + options.Privacy.MaxCustomDimensions = privacy.MaxCustomDimensions; + options.Privacy.MaxCustomValues = privacy.MaxCustomValues; + options.Privacy.MaxValueLength = privacy.MaxValueLength; + + var sessions = settings.Sessions; + options.Sessions.Enabled = sessions.Enabled; + options.Sessions.Timeout = TimeSpan.FromMinutes(sessions.TimeoutMinutes); + options.Sessions.MaxCachedSessions = sessions.MaxCachedSessions; + options.Sessions.Dimensions.AddRange(sessions.Dimensions); + + var retention = settings.Retention; + options.Retention.RawEvents = Days(retention.RawEventsDays); + options.Retention.MinuteRollups = Days(retention.MinuteRollupsDays); + options.Retention.HourRollups = Days(retention.HourRollupsDays); + options.Retention.DayRollups = Days(retention.DayRollupsDays); + options.Retention.CheckInterval = retention.CheckIntervalMinutes is { } minutes ? TimeSpan.FromMinutes(minutes) : null; + + var rollups = settings.Rollups; + options.Rollups.Granularities = rollups.Granularities.Aggregate(default(Granularity), (all, granularity) => all | granularity switch + { + RollupGranularity.Minute => Granularity.Minute, + RollupGranularity.Hour => Granularity.Hour, + _ => Granularity.Day, + }); + options.Rollups.Definitions.Clear(); + foreach (var definition in rollups.Definitions) + options.Rollups.Definitions.Add(new RollupDefinition([.. definition?.Dimensions ?? []]) { Events = definition?.Events?.ToArray() }); + foreach (var value in rollups.ValueAggregates) + { + var aggregates = (value.Percentiles ? ValueAggregates.Percentiles : ValueAggregates.None) + | (value.FirstAndLast ? ValueAggregates.FirstAndLast : ValueAggregates.None); + options.Rollups.ValueAggregates[value.Value ?? ""] = aggregates; + } + options.Rollups.DistinctDimensions.AddRange(rollups.DistinctDimensions); + options.Rollups.HyperLogLogPrecision = rollups.HyperLogLogPrecision; + + var indexes = settings.Indexes; + options.Storage.DimensionIndexes.Dimensions.AddRange(indexes.Dimensions); + options.Storage.DimensionIndexes.RecordsPerPartition = indexes.DimensionRecordsPerPartition; + foreach (var index in indexes.Rollups) + options.Storage.RollupIndexes.Definitions.Add(new RollupIndexDefinition(index.Dimension, [.. index.GroupDimensions ?? []])); + options.Storage.RollupIndexes.RecordsPerPartition = indexes.RollupRecordsPerPartition; + + var ingestion = settings.Ingestion; + options.Ingestion.WorkerCount = ingestion.WorkerCount; + options.Ingestion.RollupWriterCount = ingestion.RollupWriterCount; + options.Ingestion.QueueCapacity = ingestion.QueueCapacity; + options.Ingestion.QueueFullBehavior = ingestion.QueueFullBehavior; + options.Ingestion.FlushInterval = TimeSpan.FromSeconds(ingestion.FlushIntervalSeconds); + options.Ingestion.MaxBufferedRollupRows = ingestion.MaxBufferedRollupRows; + options.Ingestion.MaxFutureSkew = TimeSpan.FromMinutes(ingestion.MaxFutureSkewMinutes); + + var queries = settings.Queries; + options.Queries.Timeout = queries.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null; + options.Queries.MaxDegreeOfParallelism = queries.MaxDegreeOfParallelism > 0 ? queries.MaxDegreeOfParallelism : Environment.ProcessorCount; + options.Queries.MaxGroupBy = queries.MaxGroupBy; + options.Queries.MaxGroups = queries.MaxGroups; + options.Queries.MaxTimeBuckets = queries.MaxTimeBuckets; + options.Queries.MaxFilledRows = queries.MaxFilledRows; + + var storage = settings.Storage; + options.Storage.StoreRawEvents = storage.StoreRawEvents; + options.Storage.WriteAheadLogDirectory = string.IsNullOrWhiteSpace(storage.WriteAheadLogDirectory) ? null + : Path.IsPathRooted(storage.WriteAheadLogDirectory) ? storage.WriteAheadLogDirectory + : Path.GetFullPath(Path.Combine(contentRoot, storage.WriteAheadLogDirectory)); + options.Storage.PartitionIdleTimeout = TimeSpan.FromMinutes(storage.PartitionIdleTimeoutMinutes); + options.Storage.Partitioning.RawEvents = storage.RawEventsPerPartition; + options.Storage.Partitioning.MinuteRollups = storage.MinuteRollupsPerPartition; + options.Storage.Partitioning.HourRollups = storage.HourRollupsPerPartition; + options.Storage.Partitioning.DayRollups = storage.DayRollupsPerPartition; + Apply(storage.RawEventStore, options.Storage.RawEvents); + Apply(storage.RollupStore, options.Storage.Rollups); + Apply(storage.SessionStore, options.Storage.Sessions); + Apply(storage.DimensionIndexStore, options.Storage.DimensionIndexes.Store); + Apply(storage.RollupIndexStore, options.Storage.RollupIndexes.Store); + return options; + } + + static TimeSpan? Days(int? days) => days is { } value ? TimeSpan.FromDays(value) : null; + + static void Apply(StoreSettings settings, StoreOptions store) + { + store.WriteAheadLogMode = settings.WriteAheadLogMode; + store.MutableSegmentMaxItemCount = settings.MutableSegmentMaxItemCount; + store.DiskSegmentMaxItemCount = settings.DiskSegmentMaxItemCount; + store.EnableSingleSegmentGarbageCollection = settings.EnableSingleSegmentGarbageCollection; + } + + // The settings that ASP.NET Core reads on every request apply without reopening the engine. + public static void ApplyLive(DashboardSettings settings, AnalyticsWebOptions web) + { + web.AllowedClientEvents = settings.Tracking.AllowedClientEvents.Count == 0 ? null + : new HashSet(settings.Tracking.AllowedClientEvents, StringComparer.Ordinal); + web.MaxPayloadBytes = settings.Tracking.MaxPayloadBytes; + web.MaxReportingPayloadBytes = settings.Reporting.MaxRequestBytes; + web.MaxReportingResponseBytes = settings.Reporting.MaxResponseBytes; + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsPresets.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsPresets.cs new file mode 100644 index 0000000..7973ca9 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsPresets.cs @@ -0,0 +1,66 @@ +using ZoneTree.Analytics; + +// Starting points offered by the setup wizard and the admin panel. Each preset sets the listed +// settings and leaves the rest unchanged. +static class SettingsPresets +{ + public static readonly IReadOnlyList All = + [ + new("collection", "privacy-first", "Privacy first", + "No cookies, country-level location, no versions or query strings. Raw events for 30 days.", + new Dictionary + { + ["privacy.visitorIdentification"] = nameof(VisitorIdentification.DailyRotatingHash), + ["privacy.geoPrecision"] = nameof(GeoPrecision.Country), + ["privacy.collectUserAgentDetails"] = true, + ["privacy.collectVersions"] = false, + ["privacy.includeQueryString"] = false, + ["retention.rawEventsDays"] = 30, + ["retention.minuteRollupsDays"] = 2, + ["retention.hourRollupsDays"] = 90, + ["retention.dayRollupsDays"] = 730, + }), + new("collection", "standard", "Standard", + "No cookies, city-level location. Raw events for a year, daily reports forever.", + new Dictionary + { + ["privacy.visitorIdentification"] = nameof(VisitorIdentification.DailyRotatingHash), + ["privacy.geoPrecision"] = nameof(GeoPrecision.City), + ["privacy.collectUserAgentDetails"] = true, + ["privacy.collectVersions"] = false, + ["privacy.includeQueryString"] = false, + ["retention.rawEventsDays"] = 365, + ["retention.minuteRollupsDays"] = 2, + ["retention.hourRollupsDays"] = 400, + ["retention.dayRollupsDays"] = null, + }), + new("collection", "full-detail", "Full detail", + "Recognizes returning visitors, keeps versions and query strings. Raw events for three years.", + new Dictionary + { + ["privacy.visitorIdentification"] = nameof(VisitorIdentification.PersistentHash), + ["privacy.geoPrecision"] = nameof(GeoPrecision.City), + ["privacy.collectUserAgentDetails"] = true, + ["privacy.collectVersions"] = true, + ["privacy.includeQueryString"] = true, + ["retention.rawEventsDays"] = 365 * 3, + ["retention.minuteRollupsDays"] = 7, + ["retention.hourRollupsDays"] = 800, + ["retention.dayRollupsDays"] = null, + }), + new("indexes", "dashboard-filters", "Dashboard filters", + "Indexes source, country, device and page, and every pair of them, so filtered overview reports stay fast.", + new Dictionary + { + ["indexes.dimensions"] = DashboardIndexPreset.Dimensions, + ["indexes.rollups"] = DashboardIndexPreset.RollupIndexes(), + }), + new("indexes", "no-indexes", "No indexes", + "Fastest ingestion and least storage. Filtered reports read rollups and raw events.", + new Dictionary + { + ["indexes.dimensions"] = Array.Empty(), + ["indexes.rollups"] = Array.Empty(), + }), + ]; +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsSchema.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsSchema.cs new file mode 100644 index 0000000..3cac3db --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsSchema.cs @@ -0,0 +1,142 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Text; +using System.Text.Json; + +// When a changed setting takes effect: at once, after the engine reopens, after the host +// restarts, or never (chosen during setup and stored with the data). +enum SettingTier { Live, Reopen, Restart, Fixed } + +[AttributeUsage(AttributeTargets.Property)] +sealed class SectionAttribute(string label) : Attribute +{ + public string Label { get; } = label; + public string Help { get; init; } +} + +[AttributeUsage(AttributeTargets.Property)] +sealed class SettingAttribute(string label) : Attribute +{ + public string Label { get; } = label; + public string Help { get; init; } + public string Unit { get; init; } + public double Min { get; init; } = double.NaN; + public double Max { get; init; } = double.NaN; + public SettingTier Tier { get; init; } = SettingTier.Reopen; + public bool Advanced { get; init; } + // The choice that stores null, for example "Keep forever". + public string EmptyLabel { get; init; } + // Fixed choices: "value:label|value:label". + public string Options { get; init; } + // A dedicated editor instead of the field type's default. + public string Editor { get; init; } + public string KeyLabel { get; init; } + public string ValueLabel { get; init; } +} + +sealed record SettingOption(object Value, string Label); + +sealed record SettingField(string Key, string Label, string Help, string Type, string Unit, double? Min, double? Max, + bool Nullable, string EmptyLabel, IReadOnlyList Options, SettingTier Tier, bool Advanced, + string KeyLabel, string ValueLabel, IReadOnlyList Fields); + +sealed record SettingSection(string Key, string Label, string Help, IReadOnlyList Fields); + +sealed record SettingsPreset(string Group, string Key, string Label, string Description, IReadOnlyDictionary Values); + +sealed record SettingsSchemaDocument(IReadOnlyList Sections, IReadOnlyList Presets); + +// Describes DashboardSettings from its [Section] and [Setting] metadata. Keys are the camel-case +// JSON paths of the settings document, for example "privacy.geoPrecision". +static class SettingsSchema +{ + static readonly Lazy document = new(Build); + static readonly ConcurrentDictionary byKey = new(StringComparer.Ordinal); + + public static SettingsSchemaDocument Document => document.Value; + + public static SettingField Find(string key) + { + _ = document.Value; + return byKey.TryGetValue(key, out var field) ? field : null; + } + + static SettingsSchemaDocument Build() + { + var sections = new List(); + foreach (var property in typeof(DashboardSettings).GetProperties()) + { + var section = property.GetCustomAttribute(); + if (section == null) continue; + var key = Camel(property.Name); + sections.Add(new(key, section.Label, section.Help, Fields(property.PropertyType, key))); + } + return new(sections, SettingsPresets.All); + } + + static List Fields(Type type, string prefix) + { + var fields = new List(); + foreach (var property in type.GetProperties()) + { + var setting = property.GetCustomAttribute(); + if (setting == null) continue; + var key = prefix + "." + Camel(property.Name); + var propertyType = property.PropertyType; + var underlying = System.Nullable.GetUnderlyingType(propertyType); + var valueType = underlying ?? propertyType; + var nullable = underlying != null || (setting.EmptyLabel != null && valueType == typeof(string)); + IReadOnlyList children = null; + IReadOnlyList options = null; + string fieldType; + if (setting.Editor != null) fieldType = setting.Editor; + else if (valueType == typeof(bool)) fieldType = "toggle"; + else if (valueType.IsEnum) fieldType = "select"; + else if (valueType == typeof(int) || valueType == typeof(long) || valueType == typeof(double)) fieldType = "number"; + else if (valueType == typeof(string)) fieldType = "text"; + else + { + fieldType = "group"; + children = Fields(valueType, key); + } + if (setting.Options != null) + { + fieldType = "select"; + options = [.. setting.Options.Split('|').Select(option => + { + var separator = option.IndexOf(':'); + var value = option[..separator]; + return new SettingOption(int.TryParse(value, out var number) ? number : value, option[(separator + 1)..]); + })]; + } + else if (valueType.IsEnum) + options = EnumOptions(valueType); + else if (propertyType.IsGenericType && propertyType.GetGenericArguments()[0] is { IsEnum: true } itemType) + options = EnumOptions(itemType); + var field = new SettingField(key, setting.Label, setting.Help, fieldType, setting.Unit, + double.IsNaN(setting.Min) ? null : setting.Min, double.IsNaN(setting.Max) ? null : setting.Max, + nullable, setting.EmptyLabel, options, setting.Tier, setting.Advanced, setting.KeyLabel, setting.ValueLabel, children); + byKey[key] = field; + fields.Add(field); + } + return fields; + } + + static List EnumOptions(Type type) => + [.. Enum.GetNames(type).Select(name => new SettingOption(name, Humanize(name)))]; + + public static string Camel(string name) => JsonNamingPolicy.CamelCase.ConvertName(name); + + // "DailyRotatingHash" → "Daily rotating hash". + static string Humanize(string name) + { + var builder = new StringBuilder(name.Length + 8); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (i > 0 && char.IsUpper(c) && !char.IsUpper(name[i - 1])) builder.Append(' ').Append(char.ToLowerInvariant(c)); + else builder.Append(c); + } + return builder.ToString(); + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs new file mode 100644 index 0000000..30559b6 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using ZoneTree.Analytics.AspNetCore; + +sealed record SettingsReview(IReadOnlyList Changes, IReadOnlyList Warnings); + +sealed record SettingsSaveResult(DashboardSettings Settings, IReadOnlyList Changes, bool Reopened, bool RestartRequired); + +sealed class SettingsConfirmationRequiredException(IReadOnlyList warnings) + : Exception("Confirm the changes that delete or stop keeping data.") +{ + public IReadOnlyList Warnings { get; } = warnings; +} + +// The stored settings are the source of truth once setup is complete. Saving validates them, +// reopens the engine when an engine option changed, then stores them and applies the rest. +sealed class SettingsService +{ + const string Key = "settings"; + static readonly SecuritySettings DefaultSecurity = new(); + + readonly SystemStore store; + readonly EngineHost engines; + readonly AnalyticsWebOptions web; + readonly UserStore users; + readonly AuditLog audit; + readonly string contentRoot; + readonly int startupConcurrentRequests; + readonly SemaphoreSlim saving = new(1, 1); + volatile DashboardSettings current; + + public SettingsService(SystemStore store, EngineHost engines, AnalyticsWebOptions web, UserStore users, AuditLog audit, + IWebHostEnvironment environment) + { + this.store = store; + this.engines = engines; + this.web = web; + this.users = users; + this.audit = audit; + contentRoot = environment.ContentRootPath; + current = store.Get(Key); + startupConcurrentRequests = web.MaxConcurrentReportingRequests; + if (current != null) SettingsMapper.ApplyLive(current, web); + } + + public bool IsConfigured => current != null; + + // A snapshot: callers must not modify it. Use Copy() for an editable document. + public DashboardSettings Current => current; + + public DashboardSettings Copy() => current == null ? null : SettingsDocument.Clone(current); + + public SecuritySettings Security => current?.Security ?? DefaultSecurity; + + public bool RestartRequired => current != null && current.Reporting.MaxConcurrentRequests != startupConcurrentRequests; + + public string ContentRoot => contentRoot; + + // The reporting limiter reads its capacity once, when the host starts. + public static int StoredConcurrentRequests(SystemStore store) => + store.Get(Key)?.Reporting.MaxConcurrentRequests ?? new ReportingSettings().MaxConcurrentRequests; + + public static DashboardSettings CreateDefaults() + { + var settings = new DashboardSettings(); + settings.Sites.Items.Add(new SiteSettings { Id = "main", Name = "My website" }); + settings.Sites.PrimarySite = "main"; + return settings; + } + + public void Validate(DashboardSettings candidate) => SettingsMapper.Validate(candidate, contentRoot, store.Directory); + + public SettingsReview Review(DashboardSettings candidate) + { + Validate(candidate); + if (current == null) return new([], []); + var changes = SettingsDocument.Diff(current, candidate); + var fixedChange = changes.FirstOrDefault(change => change.Tier == SettingTier.Fixed); + if (fixedChange != null) + throw new SettingsValidationException($"{fixedChange.Label} is stored with the data and cannot change after setup.", fixedChange.Key); + return new(changes, DataLossWarnings(current, candidate)); + } + + public async Task SaveAsync(DashboardSettings candidate, string actor, bool confirmDataLoss, HttpContext context) + { + await saving.WaitAsync().ConfigureAwait(false); + try + { + if (current == null) throw new InvalidOperationException("Finish the setup wizard first."); + var review = Review(candidate); + if (review.Changes.Count == 0) return new(current, [], false, RestartRequired); + if (review.Warnings.Count > 0 && !confirmDataLoss) throw new SettingsConfirmationRequiredException(review.Warnings); + var previous = current; + var reopen = review.Changes.Any(change => change.Tier == SettingTier.Reopen); + if (reopen) await engines.ReopenAsync(candidate, previous).ConfigureAwait(false); + store.Put(Key, candidate); + current = candidate; + SettingsMapper.ApplyLive(candidate, web); + users.RemoveSites(candidate.Sites.Items.Select(site => site.Id).ToHashSet(StringComparer.Ordinal)); + audit.Write(actor, "settings.updated", string.Join(", ", review.Changes.Select(change => change.Label).Distinct()), context); + return new(candidate, review.Changes, reopen, RestartRequired); + } + finally { saving.Release(); } + } + + // Opens the engine with the setup wizard's settings; they are stored only when it opens. The + // caller allows it only while no administrator exists. + public async Task CompleteSetupAsync(DashboardSettings settings) + { + await saving.WaitAsync().ConfigureAwait(false); + try + { + Validate(settings); + await engines.ReopenAsync(settings, current).ConfigureAwait(false); + store.Put(Key, settings); + current = settings; + SettingsMapper.ApplyLive(settings, web); + } + finally { saving.Release(); } + } + + static List DataLossWarnings(DashboardSettings before, DashboardSettings after) + { + var warnings = new List(); + Retention("Raw events", before.Retention.RawEventsDays, after.Retention.RawEventsDays); + Retention("Minute rollups", before.Retention.MinuteRollupsDays, after.Retention.MinuteRollupsDays); + Retention("Hourly rollups", before.Retention.HourRollupsDays, after.Retention.HourRollupsDays); + Retention("Daily rollups", before.Retention.DayRollupsDays, after.Retention.DayRollupsDays); + if (before.Storage.StoreRawEvents && !after.Storage.StoreRawEvents) + warnings.Add("New raw events will not be stored: ad-hoc queries, real-time counts and erasure requests cover only earlier events."); + var removedGranularities = before.Rollups.Granularities.Except(after.Rollups.Granularities).ToList(); + if (removedGranularities.Count > 0) + warnings.Add($"{string.Join(", ", removedGranularities)} rollups stop being kept; their reports are answered from raw events while they last."); + var kept = after.Rollups.Definitions.Select(Id).ToHashSet(StringComparer.Ordinal); + var removed = before.Rollups.Definitions.Select(Id).Where(id => !kept.Contains(id)).ToList(); + if (removed.Count > 0) + warnings.Add($"Rollups stop being kept for {string.Join("; ", removed.Select(id => id.Length == 0 ? "totals" : id))}. " + + "Their reports are answered from raw events, so periods without raw events lose them."); + return warnings; + + void Retention(string label, int? previous, int? next) + { + if (next is { } days && (previous == null || days < previous)) + warnings.Add($"{label} older than {days} days will be deleted."); + } + + static string Id(RollupDefinitionSettings definition) => + string.Join(",", (definition.Dimensions ?? []).Order(StringComparer.Ordinal)) + + (definition.Events == null ? "" : " (" + string.Join(",", definition.Events.Order(StringComparer.Ordinal)) + ")"); + } + + public static JsonElement ToJson(DashboardSettings settings) => JsonSerializer.SerializeToElement(settings, SystemStore.Json); +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupBanner.cs b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupBanner.cs new file mode 100644 index 0000000..a566664 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupBanner.cs @@ -0,0 +1,52 @@ +// Prints the setup link below everything else the host prints at startup, so it is the last +// thing in the console. It is written directly to the console: the logger writes from a +// background queue, and PureStack keeps logging its first render after the host has started. +static class SetupBanner +{ + static readonly TimeSpan Quiet = TimeSpan.FromSeconds(1.5); + static readonly TimeSpan MaxWait = TimeSpan.FromSeconds(60); + + public static async Task ShowAsync(string dashboardUrl, SetupKey key, SetupStatus setup, + DashboardDevelopmentServer developmentServer, CancellationToken stopping) + { + try + { + var deadline = DateTime.UtcNow + MaxWait; + do + await Task.Delay(TimeSpan.FromMilliseconds(500), stopping); + while (developmentServer != null && DateTime.UtcNow < deadline && DateTime.UtcNow - developmentServer.LastOutputUtc < Quiet); + } + catch (OperationCanceledException) + { + return; + } + var value = key.Value; + if (setup.Complete || value == null) return; + var link = $"{dashboardUrl.TrimEnd('/')}/setup/?key={value}"; + string[] lines = + [ + "Finish setting up ZoneTree Analytics:", + "", + $" {link}", + "", + $"Setup key: {value}", + "The key changes on every start and stops working once setup is complete.", + ]; + var width = lines.Max(line => line.Length) + 4; + var border = "+" + new string('-', width) + "+"; + var output = Console.Out; + var colored = !Console.IsOutputRedirected; + lock (output) + { + output.WriteLine(); + if (colored) Console.ForegroundColor = ConsoleColor.Cyan; + output.WriteLine(border); + foreach (var line in lines) + output.WriteLine("| " + line.PadRight(width - 2) + "|"); + output.WriteLine(border); + if (colored) Console.ResetColor(); + output.WriteLine(); + output.Flush(); + } + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs new file mode 100644 index 0000000..9e533e5 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs @@ -0,0 +1,94 @@ +using ZoneTree.Analytics; + +sealed record SetupKeyRequest(string Key); +sealed record SetupValidateRequest(string Key, DashboardSettings Settings); +sealed record SetupAdministrator(string UserName, string DisplayName, string Password); +sealed record SetupCompleteRequest(string Key, SetupAdministrator Administrator, DashboardSettings Settings); + +// The first-run wizard's API. Every call that reveals or changes anything requires the setup key +// from the server log; all of it stops working once the administrator exists. +static class SetupEndpoints +{ + const string Scope = "setup"; + static readonly SemaphoreSlim completing = new(1, 1); + + public static void MapDashboardSetup(this IEndpointRouteBuilder app) + { + var setup = app.MapGroup("/_dashboard/api/setup").WithDashboardErrors().AllowAnonymous(); + setup.MapGet("/status", (SetupStatus status, EngineHost engines) => + DashboardJson.Ok(new { complete = status.Complete, engine = engines.State })); + setup.MapPost("/verify", async (HttpContext context, SetupStatus status, SetupKey key, SignInThrottle throttle) => + { + var request = await DashboardJson.ReadAsync(context.Request); + return Verify(context, status, key, throttle, request.Key) ?? DashboardJson.Ok(new + { + settings = SettingsService.CreateDefaults(), + schema = SettingsSchema.Document, + }); + }); + setup.MapPost("/validate", async (HttpContext context, SetupStatus status, SetupKey key, SignInThrottle throttle, + SettingsService settings) => + { + var request = await DashboardJson.ReadAsync(context.Request); + if (Verify(context, status, key, throttle, request.Key) is { } failure) return failure; + settings.Validate(request.Settings); + return DashboardJson.Ok(new { valid = true }); + }); + setup.MapPost("/complete", CompleteAsync); + } + + static IResult Verify(HttpContext context, SetupStatus status, SetupKey key, SignInThrottle throttle, string candidate) + { + if (status.Complete) return DashboardJson.Error("Setup is already complete. Sign in instead.", StatusCodes.Status409Conflict); + if (throttle.Paused(Scope, null, context) is { } wait) return AuthEndpoints.TooManyAttempts(context, wait); + if (key.Matches(candidate)) + { + throttle.Succeeded(Scope, null, context); + return null; + } + throttle.Failed(Scope, null, context); + return DashboardJson.Error("The setup key does not match. Copy it from the server log.", StatusCodes.Status403Forbidden); + } + + static async Task CompleteAsync(HttpContext context, SetupStatus status, SetupKey key, SignInThrottle throttle, + SettingsService settings, UserStore users, SessionStore sessions, AuditLog audit, EngineHost engines, + IConfiguration configuration, ILogger logger) + { + var request = await DashboardJson.ReadAsync(context.Request); + await completing.WaitAsync(context.RequestAborted); + try + { + if (Verify(context, status, key, throttle, request.Key) is { } failure) return failure; + var administrator = request.Administrator ?? throw new DashboardRequestException("Create the administrator account."); + var change = new UserChange(administrator.UserName, administrator.DisplayName, UserRole.Administrator, true, null, null, + administrator.Password ?? ""); + UserStore.ValidateNew(change); + settings.Validate(request.Settings); + try + { + await settings.CompleteSetupAsync(request.Settings); + } + catch (Exception error) when (error is not SettingsValidationException) + { + return DashboardJson.Error($"The analytics engine could not open with these settings: {error.Message}", + StatusCodes.Status422UnprocessableEntity); + } + var user = users.Create(change, AuthEndpoints.KnownSites(settings)); + key.Retire(); + status.MarkComplete(); + var (token, session) = sessions.Create(user, context); + SessionStore.WriteCookie(context, token, session.Expires); + users.RecordSignIn(user); + audit.Write(user.UserName, "setup.completed", $"{request.Settings.Sites.Items.Count} site(s), data in {request.Settings.General.DataDirectory}.", context); + logger.LogInformation("Setup complete. The administrator is {UserName}.", user.UserName); + if (configuration.GetValue("Analytics:SeedDemo")) + { + using var lease = await engines.AcquireAsync(context.RequestAborted); + if (lease.Engine.GetEventNames(DateRange.LastDays(60)).Count == 0) + await SampleTraffic.SeedAsync(lease.Engine); + } + return DashboardJson.Ok(AuthEndpoints.Me(context, settings, user, SessionStore.Id(token))); + } + finally { completing.Release(); } + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupKey.cs b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupKey.cs new file mode 100644 index 0000000..af67f0a --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupKey.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; +using System.Text; + +// A one-time key printed in the server log while no administrator exists. Only someone who can +// read the log can claim the server. It changes on every start and stops working after setup. +sealed class SetupKey +{ + const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + string value = Create(); + + static string Create() + { + var builder = new StringBuilder(14); + for (var i = 0; i < 12; i++) + { + if (i > 0 && i % 4 == 0) builder.Append('-'); + builder.Append(Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]); + } + return builder.ToString(); + } + + public string Value => Volatile.Read(ref value); + + public bool Matches(string candidate) + { + var current = Volatile.Read(ref value); + if (current == null || string.IsNullOrWhiteSpace(candidate)) return false; + var normalized = candidate.Trim().ToUpperInvariant().Replace(" ", ""); + if (normalized.Length == 12) normalized = $"{normalized[..4]}-{normalized[4..8]}-{normalized[8..]}"; + return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(normalized), Encoding.ASCII.GetBytes(current)); + } + + public void Retire() => Volatile.Write(ref value, null); +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/System/AuditLog.cs b/apps/ZoneTree.Analytics.Dashboard/server/System/AuditLog.cs new file mode 100644 index 0000000..48e8882 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/System/AuditLog.cs @@ -0,0 +1,26 @@ +using System.Globalization; + +sealed record AuditEntry(string Id, DateTime Time, string Actor, string Action, string Detail, string IpAddress); + +// Who changed what, newest first. Entries are kept under audit/{utc ticks}-{sequence}. +sealed class AuditLog(SystemStore store) +{ + const string Prefix = "audit/"; + long sequence; + + sealed record Record(DateTime Time, string Actor, string Action, string Detail, string IpAddress); + + public void Write(string actor, string action, string detail, HttpContext context = null) + { + var now = DateTime.UtcNow; + var key = Prefix + now.Ticks.ToString("D19", CultureInfo.InvariantCulture) + "-" + + Interlocked.Increment(ref sequence).ToString("D10", CultureInfo.InvariantCulture); + store.Put(key, new Record(now, actor, action, detail, context?.Connection.RemoteIpAddress?.ToString())); + } + + // Up to take entries older than the entry named before, newest first. + public List List(string before, int take) => + [.. store.ListDescending(Prefix, before == null ? null : Prefix + before, Math.Clamp(take, 1, 500)) + .Select(entry => new AuditEntry(entry.Key[Prefix.Length..], entry.Value.Time, entry.Value.Actor, + entry.Value.Action, entry.Value.Detail, entry.Value.IpAddress))]; +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/System/SystemStore.cs b/apps/ZoneTree.Analytics.Dashboard/server/System/SystemStore.cs new file mode 100644 index 0000000..b7e4da2 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/server/System/SystemStore.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZoneTree; + +// The application's own state lives in this one ZoneTree: users, sessions, settings, secrets +// and the audit log share it under key prefixes. Values are JSON documents. +sealed class SystemStore : IDisposable +{ + public const string DefaultDirectory = "data/system"; + const string Marker = ".zonetree-dashboard-system"; + + public static readonly JsonSerializerOptions Json = CreateJsonOptions(); + + readonly IZoneTree tree; + readonly IMaintainer maintainer; + + public string Directory { get; } + + // Callers hold it across a read-check-write sequence, for example a unique user name. + public object WriteLock { get; } = new(); + + public SystemStore(string directory) + { + Directory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + EnsureOwnedDirectory(); + tree = new ZoneTreeFactory() + .SetDataDirectory(Directory) + .OpenOrCreate(); + maintainer = tree.CreateMaintainer(); + } + + public static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + void EnsureOwnedDirectory() + { + for (var parent = new DirectoryInfo(Directory); parent != null; parent = parent.Parent) + if (parent.Exists && parent.Attributes.HasFlag(FileAttributes.ReparsePoint)) + throw new InvalidOperationException("The dashboard system directory must not use symbolic links or junctions."); + if (System.IO.Directory.Exists(Directory) && !File.Exists(Path.Combine(Directory, Marker)) + && System.IO.Directory.EnumerateFileSystemEntries(Directory).Any()) + throw new InvalidOperationException($"The dashboard system directory '{Directory}' contains files it did not create. " + + "Choose an empty directory with --Dashboard:SystemDirectory=."); + System.IO.Directory.CreateDirectory(Directory); + File.WriteAllText(Path.Combine(Directory, Marker), "ZoneTree.Analytics.Dashboard users, sessions and settings"); + } + + public bool TryGet(string key, out T value) + { + if (tree.TryGet(key, out var json) && json != null) + { + value = JsonSerializer.Deserialize(json, Json); + return true; + } + value = default; + return false; + } + + public T Get(string key) => TryGet(key, out var value) ? value : default; + + public bool Contains(string key) => tree.TryGet(key, out var json) && json != null; + + public void Put(string key, T value) => tree.Upsert(key, JsonSerializer.Serialize(value, Json)); + + public void Delete(string key) => tree.ForceDelete(key); + + // Every record under the prefix, in key order. + public List> List(string prefix) + { + var result = new List>(); + using var iterator = tree.CreateIterator(); + iterator.Seek(prefix); + while (iterator.Next() && iterator.CurrentKey.StartsWith(prefix, StringComparison.Ordinal)) + result.Add(new(iterator.CurrentKey, JsonSerializer.Deserialize(iterator.CurrentValue, Json))); + return result; + } + + // Up to take records under the prefix in descending key order, starting below before (exclusive) + // or at the last key. + public List> ListDescending(string prefix, string before, int take) + { + var result = new List>(); + using var iterator = tree.CreateReverseIterator(); + iterator.Seek(before ?? prefix + char.MaxValue); + while (result.Count < take && iterator.Next() && iterator.CurrentKey.StartsWith(prefix, StringComparison.Ordinal)) + { + if (before != null && string.CompareOrdinal(iterator.CurrentKey, before) >= 0) continue; + result.Add(new(iterator.CurrentKey, JsonSerializer.Deserialize(iterator.CurrentValue, Json))); + } + return result; + } + + public void Dispose() + { + maintainer.Dispose(); + tree.Dispose(); + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/server/ZoneTree.Analytics.Dashboard.csproj b/apps/ZoneTree.Analytics.Dashboard/server/ZoneTree.Analytics.Dashboard.csproj index 55b4e0b..5d38080 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/ZoneTree.Analytics.Dashboard.csproj +++ b/apps/ZoneTree.Analytics.Dashboard/server/ZoneTree.Analytics.Dashboard.csproj @@ -6,6 +6,14 @@ false true + + + + + + + + diff --git a/apps/ZoneTree.Analytics.Dashboard/server/appsettings.json b/apps/ZoneTree.Analytics.Dashboard/server/appsettings.json index 4ded0e1..b2f72f6 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/appsettings.json +++ b/apps/ZoneTree.Analytics.Dashboard/server/appsettings.json @@ -1,9 +1,9 @@ { - "Analytics": { - "TimeZone": "UTC" - }, - "Dashboard": { - "Sites": [{ "Id": "", "Name": "Default site" }], - "RefreshSeconds": 30 + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ZoneTree.Analytics": "Information" + } } } diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/README.md b/apps/ZoneTree.Analytics.Dashboard/ui/README.md index 5da74d7..1b9832e 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/README.md +++ b/apps/ZoneTree.Analytics.Dashboard/ui/README.md @@ -11,9 +11,9 @@ yarn install yarn dashboard:dev ``` -Open **http://127.0.0.1:4500/**. PureStack's development middleware serves demo settings from `GET /_analytics/api/config`. Date ranges, comparisons, filters, breakdowns, and CSV exports work in demo mode. Advanced query expressions and ordered funnel evaluation require the live API. +Open **http://127.0.0.1:4500/**. PureStack's development middleware serves demo settings from `GET /_analytics/api/config` and acts as a signed-in administrator. Date ranges, comparisons, filters, breakdowns, and CSV exports work in demo mode. Advanced query expressions and ordered funnel evaluation require the live API. -Open **Connection** to select the live API, configure its URL, sites, reporting time zone, and refresh interval. The default reporting URL is `/_analytics/api`. Settings are stored in this browser. Optional bearer credentials are stored separately in this tab's sessionStorage; host cookies are also supported. +Sign-in, setup, account and administration pages need the .NET server; in the standalone preview they explain that instead of loading. ## Run with the real .NET engine @@ -29,24 +29,27 @@ From the repository root: yarn dashboard --Analytics:SeedDemo=true ``` -Open **http://127.0.0.1:5088/**. This .NET 10 application serves the static dashboard and reporting API on the same origin, and supplies live connection settings through `GET /_analytics/api/config`. The optional flag records synthetic traffic through the real engine on an empty store; it never erases existing data. Omit the flag to start with an empty analytics store, or set `--Analytics:DataDirectory=` to read a compatible existing store. A data directory can only be opened by one engine at a time. The application defaults to UTC and a sample geolocator. +Open **http://127.0.0.1:5088/**. This .NET 10 application serves the static dashboard and reporting API on the same origin, and supplies live connection settings through `GET /_analytics/api/config`. A fresh server opens the setup wizard: open the setup link printed at the end of the console output (or enter its key), create the administrator, and choose sites, time zone and collection. The optional flag then records synthetic traffic through the real engine on an empty primary site; it never erases existing data. To read a compatible existing store, choose its directory under **Advanced settings** during setup. A data directory can only be opened by one engine at a time. The `dotnet run` launch profile uses **Development** mode. The host starts PureStack's native development server automatically and serves its generated pages through port 5088. Editing MDX, page TypeScript, shared components, styles, or configuration rebuilds the affected pages and reloads the browser. Reporting and tracker requests still use the real .NET endpoints. No separate `yarn dev` or manual publish is needed. Stopping the host stops its PureStack child process. Set `--Dashboard:Watch=false` to serve an existing static release in Development. Override the source directory with `--Dashboard:SourcePath=` or the Node executable with `--Dashboard:NodePath=`. -In **Production**, run `yarn publish` first. The server serves `apps/ZoneTree.Analytics.Dashboard/ui/dist/publish`; override it with `--Dashboard:Path=`. The application binds to all network interfaces by default. Integrate your existing host's authentication and site authorization before exposing analytics reports outside that host. +In **Production**, run `yarn publish` first. The server serves `apps/ZoneTree.Analytics.Dashboard/ui/dist/publish`; override it with `--Dashboard:Path=`. The application binds to all network interfaces by default. Every page and report requires signing in; serve it over HTTPS outside a trusted network. -Every dashboard page loads the host's `/_analytics/script.js` tracker with localhost tracking enabled and `data-track-navigation="false"`. It records the page load without counting report-filter and date changes through `history.replaceState` as extra visits. Dashboard visits are recorded in the host's default analytics site. Page views increase on visits; unique visitors count distinct visitors. Refresh reports to see ingested traffic, or wait for the configured auto-refresh interval. Standalone PureStack previews keep synthetic demo reports and acknowledge beacons without persisting them. +Every dashboard page loads the host's `/_analytics/script.js` tracker with localhost tracking enabled and `data-track-navigation="false"`. It records the page load without counting report-filter and date changes through `history.replaceState` as extra visits. Dashboard visits are recorded in the administrator-only **Dashboard usage** site when **Record dashboard usage** is on in **Administration → Sites**, and ignored otherwise. Page views increase on visits; unique visitors count distinct visitors. Refresh reports to see ingested traffic, or wait for the configured auto-refresh interval. Standalone PureStack previews keep synthetic demo reports and acknowledge beacons without persisting them. -All engine options are centralized in `apps/ZoneTree.Analytics.Dashboard/server/DashboardAnalyticsOptions.cs`. -Edit `ConfigureCommon` for retention, storage, ingestion, queries, or rollups shared by actual -traffic, performance samples, new benchmarks and reopened benchmark reports. Raw events are -retained for three years in all datasets. `ConfigurePrimary`, `CreatePerformanceSamples`, -`CreateBenchmark` and `CreateBenchmarkReport` contain the per-engine settings; sample and -benchmark worker counts come from the dashboard, and the benchmark percentile and index settings and -reporting time zone are preserved when reopening a completed dataset. Each engine receives -independent options and collections. Restart the host to apply edited engine settings. +The primary engine is configured in **Administration**: setup chooses the first settings, and +the admin pages change them, reopening the engine when needed. The Development datasets are +configured in `apps/ZoneTree.Analytics.Dashboard/server/DashboardAnalyticsOptions.cs`: +`ConfigureCommon` holds retention, storage, ingestion, queries and rollups shared by performance +samples, new benchmarks and reopened benchmark reports, whose raw events are retained for three +years. `CreatePerformanceSamples`, `CreateBenchmark` and `CreateBenchmarkReport` contain the +per-engine settings; sample and benchmark worker counts come from the dashboard, and the benchmark +percentile and index settings and reporting time zone are preserved when reopening a completed +dataset. New sample datasets use the reporting time zone chosen during setup; an existing one keeps the +time zone it was created with. Restart the host to apply edited +sample settings. ## Performance sample data @@ -54,7 +57,7 @@ With the dashboard server in Development mode, open **Tools → Sample data Select **View sample reports**, or choose **Performance samples** in a report's site selector. These reports use a separate engine and store at `apps/ZoneTree.Analytics.Dashboard/server/data/performance-samples`. Traffic follows the reporting time zone, with quiet nights, busy daytime hours, quieter weekends, growth, campaign spikes and a temporary dip. Visits vary in length, bounce rate and conversions; campaign traffic also changes sources and load times. The dataset includes page views, signups, purchases, bot traffic, device types, sources and geography. Counts describe generated input events; the engine also produces session records. Each generation adds a batch with new visitor IDs. Delete an existing uniformly distributed dataset before generating a fresh one to see the new patterns clearly. -**Delete sample data** closes the sample engine and removes its events, rollups and metadata, then permits a fresh dataset. Primary traffic and `Analytics:DataDirectory` are preserved. Sample controls and endpoints are available only in Development mode, including when `Dashboard:Watch=false`; production hosts do not expose them. +**Delete sample data** closes the sample engine and removes its events, rollups and metadata, then permits a fresh dataset. Primary traffic and its data directory are preserved. Sample controls and endpoints are available only in Development mode, including when `Dashboard:Watch=false`; production hosts do not expose them. **Benchmark ingestion** prepares the same realistic traffic in memory before timing, then feeds it to a fresh engine with the default per-worker limits. **Include load-time percentile aggregation** controls only percentile rollups; event values and traffic shape are retained. Clear **Maintain dimension and rollup indexes** to measure ingestion without the dashboard's secondary indexes; that run's reports read rollups and raw events directly. Progress polls lightweight engine counters every 250 ms and shows ingested events, queued events, elapsed time and average throughput so far. The progress bar follows processed events, then shows a separate saving-aggregates stage while the final flush finishes. The result separates preparation, submission, and ingestion-through-flush time. Throughput uses successfully processed events divided by the submission-through-flush interval, with preparation, store creation, dataset publication, disposal and cleanup excluded. @@ -122,7 +125,7 @@ yarn publish Linting and formatting use the repository's root Biome configuration. From the repository root, run `yarn lint` and `yarn format:check` for checks, or `yarn lint:fix` and `yarn format` to apply fixes. The root `yarn check` includes linting, formatting, and TypeScript checks. -The dashboard loads its connection settings from `GET /_analytics/api/config` on the dashboard's host. The server returns the following JSON response, with sites and refresh interval supplied by `apps/ZoneTree.Analytics.Dashboard/server/appsettings.json`, and the time zone shared with the analytics engine: +The dashboard loads its settings from `GET /_analytics/api/config` on the dashboard's host. The server builds the response from the stored settings and the signed-in user, listing only the sites that user may read: ```json { @@ -130,14 +133,15 @@ The dashboard loads its connection settings from `GET /_analytics/api/config` on "apiBaseUrl": "/_analytics/api", "timeZone": "Europe/Berlin", "sites": [ - { "id": "", "name": "Default site" }, - { "id": "store", "name": "Store" } + { "id": "shop", "name": "Shop" }, + { "id": "blog", "name": "Blog" } ], - "refreshSeconds": 30 + "refreshSeconds": 30, + "user": { "id": "…", "userName": "olivia", "displayName": "Olivia", "role": "Analyst", "canQuery": true } } ``` -Set `Dashboard:Sites`, `Dashboard:RefreshSeconds`, and `Analytics:TimeZone` through ASP.NET Core configuration, including environment variables or command-line arguments. Site IDs must match the engine's sites. Supported refresh intervals are `0`, `30`, `60`, and `300` seconds. Connection settings saved in the browser override the API response; use **Reset settings** to fetch the host configuration again. For a different reporting API origin, return its URL as `apiBaseUrl` and configure that API's CORS policy to allow the dashboard origin and credentials. +Sites, the refresh interval and the time zone are dashboard settings. Supported refresh intervals are `0`, `30`, `60`, and `300` seconds. A `401` from the config endpoint or the reporting API sends the browser to `/signin/`. For a different reporting API origin, return its URL as `apiBaseUrl` and configure that API's CORS policy to allow the dashboard origin and credentials. An existing host should register this config endpoint alongside its reporting endpoints, returning only settings the current user can access. The sample registers it on the reporting route group so it shares any authorization policy applied to that group. A missing or failed config endpoint displays a connection error instead of demo reports. No static config asset is published. @@ -155,10 +159,13 @@ For hosting below a path such as `/analytics/`, set PureStack's `basePath` in `c | `/bots/` | `content/bots/bots.mdx`, `content/bots/bots.ts` | Bot requests and categories | | `/funnels/` | `content/funnels/funnels.mdx`, `content/funnels/funnels.ts` | Editable ordered funnel steps and conversion window | | `/explorer/` | `content/explorer/explorer.mdx`, `content/explorer/explorer.ts` | Editable query JSON, presets, totals, and query plan | -| `/connection/` | `content/connection/connection.mdx`, `content/connection/connection.ts` | Configuration and connection test | +| `/signin/`, `/signout/` | `content/signin/`, `content/signout/` | Sign-in form and sign-out, on the `auth` template | +| `/setup/` | `content/setup/setup.mdx`, `content/setup/setup.ts` | First-run wizard | +| `/account/`, `/account/settings/` | `content/account/` | Profile, signed-in browsers, display name and password | +| `/admin/…` | `content/admin/` | Server status, sites, users, indexes and rollups, settings, audit log | | `/sample-data/` | `content/sample-data/sample-data.mdx`, `content/sample-data/sample-data.ts` | Sample generation, progress, cancellation, and deletion | -Each page's MDX and TypeScript live together in its folder. PureStack treats matching folder and file names as folder indexes. The root `index.mdx` imports the shared overview markup so the homepage stays at `/`. Each page component has an exported interface, declarative `html` template, and business logic in the same TypeScript file. `content/components` contains shared typed components with their templates and behavior. `content/lib` contains HTTP contracts, transport, configuration, demo data, and formatting helpers. `design` holds the PureStack plugin, build-time components, skin, and typed styles. +Each page's MDX and TypeScript live together in its folder. PureStack treats matching folder and file names as folder indexes. The root `index.mdx` imports the shared overview markup so the homepage stays at `/`. Each page component has an exported interface, declarative `html` template, and business logic in the same TypeScript file. `content/components` contains shared typed components with their templates and behavior. `content/lib` contains HTTP contracts, transport, configuration, demo data, and formatting helpers; `session.ts`, `admin.ts` and `settings-model.ts` hold the session, the review-before-save flow and the schema-driven settings form shared by the wizard and the admin pages. `design` holds the PureStack plugin, build-time components, skin, and typed styles. PureStack provides route generation, header partials, browser entry bundles, icons, the logo and favicon, theme switching, theme palettes, component styles, and static publishing. The plugin selects browser icons from PureStack's catalog at build time and emits a small `assets/dashboard-icons.json`, keeping the full catalog out of page bundles. Regor binds state, conditions, loops, component props, and events. The dashboard uses PureStack's Panel, Btn, Badge, AppForm, FormInputField, FormSelectField, FormStatus, LineChart, and DoughnutChart components. Report values are rendered through text bindings; there is no HTML-string renderer. diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.mdx new file mode 100644 index 0000000..c3ff61b --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.mdx @@ -0,0 +1,12 @@ +--- +title: Account +description: Your profile and signed-in devices. +template: dashboard +section: account +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.ts new file mode 100644 index 0000000..b0fcc71 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/account.ts @@ -0,0 +1,95 @@ +import { computed, defineComponent, html, sref } from 'regor' +import type { MeResponse, SessionView } from '../lib/admin-contracts' +import { + describeAgent, + formatDate, + mountPage, + roleDescriptions, +} from '../lib/page' +import { dashboardFetch, loadMe } from '../lib/session' + +const template = html`
+
+
YOUR ACCOUNT
+

{{ me ? me.user.displayName : 'Account' }}

+

Your profile and the browsers signed in to your account.

+
+ +
+{{ error }} +
+ +

Profile

+
+
User name
{{ me.user.userName }}
+
Display name
{{ me.user.displayName }}
+
Role
{{ me.user.role }}{{ roleDescription }}
+
Sites
{{ siteList }}
+
Member since
{{ formatDate(me.user.created) }}
+
+
+ +

Signed-in browsers

+

End a session you don't recognize. Changing your password ends every other session.

+
    +
  • +
    + {{ describeAgent(session.userAgent) }} + This browser + {{ session.ipAddress || 'Unknown address' }} · last active {{ formatDate(session.lastSeen) }} +
    + Sign out +
  • +
+
+
` + +const page = defineComponent(template, { + context: () => { + const me = sref(null) + const sessions = sref([]) + const error = sref('') + const refresh = async () => { + try { + me(await loadMe()) + sessions(await dashboardFetch('/auth/me/sessions')) + } catch (failure) { + error((failure as Error).message) + } + } + void refresh() + return { + me, + sessions, + error, + formatDate, + describeAgent, + roleDescription: computed( + () => roleDescriptions[me()?.user.role ?? ''] ?? '', + ), + siteList: computed(() => { + const current = me() + if (!current) return '' + if (current.user.allSites) return 'All sites' + return ( + current.sites.map((site) => site.name).join(', ') || 'No sites yet' + ) + }), + revoke: async (session: SessionView) => { + try { + await dashboardFetch( + `/auth/me/sessions/${encodeURIComponent(session.id)}`, + { method: 'DELETE' }, + ) + await refresh() + } catch (failure) { + error((failure as Error).message) + } + }, + } + }, +}) + +mountPage({ selector: '#account-app', component: page }) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/account-settings.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/account-settings.ts new file mode 100644 index 0000000..a0078d6 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/account-settings.ts @@ -0,0 +1,104 @@ +import { defineComponent, html, sref } from 'regor' +import type { MeResponse } from '../../lib/admin-contracts' +import { mountPage } from '../../lib/page' +import { applyUser, dashboardFetch, loadMe } from '../../lib/session' + +const template = html`
+
+
YOUR ACCOUNT
+

Account settings

+

Change how your name appears and the password you sign in with.

+
+
+
+ +

Profile

+ + + {{ profileStatus }} + {{ profileError }} +
+ Save profile +
+
+
+ +

Password

+

Changing your password signs out every other browser.

+ + + + + {{ passwordStatus }} + {{ passwordError }} +
+ Change password +
+
+
+
` + +const page = defineComponent(template, { + context: () => { + const displayName = sref('') + const current = sref('') + const next = sref('') + const confirm = sref('') + const profileStatus = sref('') + const profileError = sref('') + const passwordStatus = sref('') + const passwordError = sref('') + void loadMe() + .then((me) => displayName(me.user.displayName)) + .catch((failure: Error) => profileError(failure.message)) + return { + displayName, + current, + next, + confirm, + profileStatus, + profileError, + passwordStatus, + passwordError, + saveProfile: async () => { + profileStatus('') + profileError('') + try { + const me = await dashboardFetch('/auth/me/profile', { + method: 'PUT', + body: { displayName: displayName() }, + }) + applyUser(me.user) + displayName(me.user.displayName) + profileStatus('Profile saved.') + } catch (failure) { + profileError((failure as Error).message) + } + }, + savePassword: async () => { + passwordStatus('') + passwordError('') + if (next() !== confirm()) { + passwordError('The new passwords do not match.') + return + } + try { + await dashboardFetch('/auth/me/password', { + body: { currentPassword: current(), newPassword: next() }, + }) + current('') + next('') + confirm('') + passwordStatus('Password changed. Other browsers are signed out.') + } catch (failure) { + passwordError((failure as Error).message) + } + }, + } + }, +}) + +mountPage({ selector: '#account-settings-app', component: page }) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/settings.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/settings.mdx new file mode 100644 index 0000000..46d22f0 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/account/settings/settings.mdx @@ -0,0 +1,12 @@ +--- +title: Account settings +description: Change your display name and password. +template: dashboard +section: account +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.mdx new file mode 100644 index 0000000..8250f01 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.mdx @@ -0,0 +1,12 @@ +--- +title: Administration +description: Engine status, storage and pending changes. +template: dashboard +section: admin +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.ts new file mode 100644 index 0000000..7a12a55 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/admin.ts @@ -0,0 +1,126 @@ +import { computed, defineComponent, html, sref } from 'regor' +import type { AdminStatus } from '../lib/admin-contracts' +import { formatDate, mountPage } from '../lib/page' +import { dashboardFetch, loadMe } from '../lib/session' + +const stateLabels: Record = { + NotConfigured: 'Not set up', + Opening: 'Opening', + Running: 'Running', + Reopening: 'Applying settings', + Failed: 'Failed to open', +} + +const template = html`
+
+
ADMINISTRATION
+

Server status

+

The analytics engine, background index work and storage.

+
+
+ Refresh +
+
+{{ error }} + + A saved setting applies after the server restarts: the number of concurrent reports. + + + The engine could not open: {{ status.engine.error }} + +
+ + Engine + {{ stateLabel }} + {{ status.engine.openedAt ? 'Since ' + formatDate(status.engine.openedAt) : '—' }} + + + Indexes + {{ status.engine.indexMaintenance ? 'Building' : 'Up to date' }} + {{ status.engine.indexMaintenance || 'No background work' }} + + + Events processed + {{ number(status.engine.eventsProcessed) }} + {{ number(status.engine.eventsAccepted) }} accepted · {{ number(status.engine.eventsDropped) }} dropped since opening + + + Queue + {{ number(status.engine.queueLength) }} + {{ number(status.engine.errors) }} errors · {{ number(status.engine.openPartitions) }} open partitions + +
+
+ +

Server

+
+ + +
Analytics data
{{ status.dataDirectory }}
+
Users and settings
{{ status.systemDirectory }}
+
+
+ +

Partitions

+

ZoneTree partitions of each data tier, with approximate record counts.

+
+ + + + + + + + +
PartitionRecords
{{ partition.directory }}{{ partition.records === null ? '—' : number(partition.records) }}
+
+

No partitions are open.

+
+
` + +const page = defineComponent(template, { + context: () => { + const status = sref(null) + const error = sref('') + const loading = sref(false) + let timer = 0 + const refresh = async () => { + loading(true) + try { + const next = await dashboardFetch('/admin/status') + status(next) + error('') + // Follow background work closely, otherwise check occasionally. + const busy = + !!next.engine.indexMaintenance || + next.engine.state === 'Reopening' || + next.engine.state === 'Opening' + window.clearTimeout(timer) + timer = window.setTimeout(refresh, busy ? 2000 : 15000) + } catch (failure) { + error((failure as Error).message) + } finally { + loading(false) + } + } + void loadMe() + .then(refresh) + .catch((failure: Error) => error(failure.message)) + window.addEventListener('pagehide', () => window.clearTimeout(timer), { + once: true, + }) + return { + status, + error, + loading, + refresh, + formatDate, + number: (value: number) => value.toLocaleString(), + stateLabel: computed( + () => stateLabels[status()?.engine.state ?? ''] ?? '—', + ), + } + }, +}) + +mountPage({ selector: '#admin-app', component: page }) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.mdx new file mode 100644 index 0000000..aecf5b0 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.mdx @@ -0,0 +1,12 @@ +--- +title: Audit log +description: Who changed what, and when. +template: dashboard +section: admin-audit +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.ts new file mode 100644 index 0000000..3f04d53 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/audit/audit.ts @@ -0,0 +1,99 @@ +import { defineComponent, html, sref } from 'regor' +import type { AuditEntry } from '../../lib/admin-contracts' +import { formatDate, mountPage } from '../../lib/page' +import { dashboardFetch, loadMe } from '../../lib/session' + +const pageSize = 100 + +const actionLabels: Record = { + 'setup.completed': 'Completed setup', + 'auth.signin': 'Signed in', + 'auth.signin-failed': 'Failed sign-in', + 'auth.signout': 'Signed out', + 'auth.password-changed': 'Changed password', + 'settings.updated': 'Changed settings', + 'user.created': 'Created user', + 'user.updated': 'Updated user', + 'user.deleted': 'Deleted user', + 'user.sessions-ended': 'Signed a user out everywhere', + 'session.ended': 'Ended a session', +} + +const template = html`
+
+
ADMINISTRATION
+

Audit log

+

Sign-ins, setting changes and user management, newest first.

+
+
+ Refresh +
+
+{{ error }} + +
+ + + + + + + + + + + + + + + + + + + +
TimeWhoWhatDetailsAddress
{{ formatDate(entry.time) }}{{ entry.actor || '—' }}{{ label(entry.action) }}{{ entry.detail || '' }}{{ entry.ipAddress || '' }}
+
+

Nothing has been recorded yet.

+
+ Show older entries +
+
` + +const page = defineComponent(template, { + context: () => { + const entries = sref([]) + const error = sref('') + const loading = sref(false) + const more = sref(false) + const fetchPage = async (before: string | null) => { + loading(true) + try { + const query = new URLSearchParams({ take: String(pageSize) }) + if (before) query.set('before', before) + const next = await dashboardFetch(`/admin/audit?${query}`) + entries(before ? [...entries(), ...next] : next) + more(next.length === pageSize) + error('') + } catch (failure) { + error((failure as Error).message) + } finally { + loading(false) + } + } + void loadMe() + .then(() => fetchPage(null)) + .catch((failure: Error) => error(failure.message)) + return { + entries, + error, + loading, + more, + formatDate, + label: (action: string) => actionLabels[action] ?? action, + reload: () => fetchPage(null), + loadMore: () => fetchPage(entries()[entries().length - 1]?.id ?? null), + } + }, +}) + +mountPage({ selector: '#admin-audit-app', component: page }) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.mdx new file mode 100644 index 0000000..192e045 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.mdx @@ -0,0 +1,12 @@ +--- +title: Indexes and rollups +description: Pre-aggregated reports and secondary indexes. +template: dashboard +section: admin-indexes +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.ts new file mode 100644 index 0000000..045fb97 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/indexes/indexes.ts @@ -0,0 +1,298 @@ +import { computed, defineComponent, html, type SRef, sref } from 'regor' +import { defineSettingsFieldComponents } from '../../components/settings-fields' +import { createSaveFlow, defineSaveReview, loadAdmin } from '../../lib/admin' +import type { + AdminStatus, + DashboardSettings, + SettingsPreset, + SettingsSchema, +} from '../../lib/admin-contracts' +import { mountPage } from '../../lib/page' +import { dashboardFetch } from '../../lib/session' +import { + applyPreset, + cloneSettings, + presetMatches, + SettingsForm, + splitList, +} from '../../lib/settings-model' + +interface RollupIndexRow { + dimension: SRef + groups: SRef +} +interface RollupRow { + dimensions: SRef + events: SRef +} +interface ValueRow { + value: SRef + percentiles: SRef + firstAndLast: SRef +} + +const template = html`
+
+
ADMINISTRATION
+

Indexes and rollups

+

Pre-aggregate the reports you read most, and index the filters you use most.

+
+
+{{ error }} + + Building in the background: {{ building }}. Reports use an index once it is complete. + +` + +const page = defineComponent(template, { + context: () => { + const loaded = sref(false) + const error = sref('') + const advanced = sref(false) + const building = sref('') + const schema = sref(null) + const form = sref(null) + const dimensionIndexes = sref('') + const rollupIndexes = sref([]) + const rollups = sref([]) + const values = sref([]) + const selectedPreset = sref('') + let timer = 0 + + const indexRow = ( + dimension = '', + groups: string[] = [], + ): RollupIndexRow => ({ + dimension: sref(dimension), + groups: sref(groups.join(', ')), + }) + const rollupRow = ( + dimensions: string[] = [], + events: string[] | null = null, + ): RollupRow => ({ + dimensions: sref(dimensions.join(', ')), + events: sref((events ?? []).join(', ')), + }) + const valueRow = ( + value = '', + percentiles = false, + firstAndLast = false, + ): ValueRow => ({ + value: sref(value), + percentiles: sref(percentiles), + firstAndLast: sref(firstAndLast), + }) + const presets = computed(() => + (schema()?.presets ?? []).filter((preset) => preset.group === 'indexes'), + ) + + function show(settings: DashboardSettings) { + const current = cloneSettings(settings) + form( + new SettingsForm( + current, + (schema()?.sections ?? []).filter( + (s) => s.key === 'rollups' || s.key === 'indexes', + ), + true, + ), + ) + dimensionIndexes(current.indexes.dimensions.join(', ')) + rollupIndexes( + current.indexes.rollups.map((index) => + indexRow(index.dimension, index.groupDimensions), + ), + ) + rollups( + current.rollups.definitions.map((definition) => + rollupRow(definition.dimensions, definition.events), + ), + ) + values( + current.rollups.valueAggregates.map((value) => + valueRow(value.value, value.percentiles, value.firstAndLast), + ), + ) + selectedPreset( + presets().find((preset) => presetMatches(current, preset))?.key ?? '', + ) + loaded(true) + } + + function collect(): DashboardSettings { + const settings = (form() as SettingsForm).collect() + settings.indexes.dimensions = splitList(dimensionIndexes()) + settings.indexes.rollups = rollupIndexes() + .filter((row) => row.dimension().trim()) + .map((row) => ({ + dimension: row.dimension().trim(), + groupDimensions: splitList(row.groups()), + })) + settings.rollups.definitions = rollups().map((row) => { + const events = splitList(row.events()) + return { + dimensions: splitList(row.dimensions()), + events: events.length ? events : null, + } + }) + settings.rollups.valueAggregates = values() + .filter((row) => row.value().trim()) + .map((row) => ({ + value: row.value().trim(), + percentiles: row.percentiles(), + firstAndLast: row.firstAndLast(), + })) + return settings + } + + const watchBuild = async () => { + try { + const status = await dashboardFetch('/admin/status') + building(status.engine.indexMaintenance ?? '') + window.clearTimeout(timer) + if ( + status.engine.indexMaintenance || + status.engine.state === 'Reopening' + ) + timer = window.setTimeout(watchBuild, 2000) + } catch { + building('') + } + } + window.addEventListener('pagehide', () => window.clearTimeout(timer), { + once: true, + }) + + const flow = createSaveFlow((saved) => { + show(saved) + void watchBuild() + }) + void loadAdmin() + .then((context) => { + schema(context.schema) + show(context.settings) + void watchBuild() + }) + .catch((failure: Error) => error(failure.message)) + + return { + loaded, + error, + advanced, + building, + form, + dimensionIndexes, + rollupIndexes, + rollups, + values, + presets, + selectedPreset, + flow, + sections: computed(() => + (schema()?.sections ?? []).filter( + (section) => section.key === 'rollups' || section.key === 'indexes', + ), + ), + choose: (preset: SettingsPreset) => { + const settings = collect() + applyPreset(settings, preset) + dimensionIndexes(settings.indexes.dimensions.join(', ')) + rollupIndexes( + settings.indexes.rollups.map((index) => + indexRow(index.dimension, index.groupDimensions), + ), + ) + selectedPreset(preset.key) + }, + addIndex: () => rollupIndexes([...rollupIndexes(), indexRow()]), + removeIndex: (row: RollupIndexRow) => + rollupIndexes(rollupIndexes().filter((item) => item !== row)), + addRollup: () => rollups([...rollups(), rollupRow()]), + removeRollup: (row: RollupRow) => + rollups(rollups().filter((item) => item !== row)), + addValue: () => values([...values(), valueRow()]), + removeValue: (row: ValueRow) => + values(values().filter((item) => item !== row)), + save: () => flow.review(collect), + } + }, +}) + +mountPage({ + selector: '#admin-indexes-app', + component: page, + components: { + ...defineSettingsFieldComponents(), + SaveReview: defineSaveReview(), + }, +}) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.mdx new file mode 100644 index 0000000..66862c2 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.mdx @@ -0,0 +1,12 @@ +--- +title: Settings +description: Collection, privacy, retention and engine tuning. +template: dashboard +section: admin-settings +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.ts new file mode 100644 index 0000000..6daeb2b --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/settings/settings.ts @@ -0,0 +1,146 @@ +import { computed, defineComponent, html, sref } from 'regor' +import { defineSettingsFieldComponents } from '../../components/settings-fields' +import { createSaveFlow, defineSaveReview, loadAdmin } from '../../lib/admin' +import type { + DashboardSettings, + SettingSection, + SettingsPreset, + SettingsSchema, +} from '../../lib/admin-contracts' +import { mountPage } from '../../lib/page' +import { + applyPreset, + cloneSettings, + presetMatches, + SettingsForm, +} from '../../lib/settings-model' + +// Sites and indexes have their own pages. +const excluded = new Set(['sites', 'indexes']) + +const template = html`
+
+
ADMINISTRATION
+

Settings

+

Every option of the analytics engine and this dashboard. Changes are reviewed before they apply.

+
+
+{{ error }} + + A saved setting applies after the server restarts: the number of concurrent reports. + +
+ +
+ +

{{ section.label }}

+

{{ section.help }}

+
+ +
+ + +
+
+ Review changes + Discard edits +
+ +
+
` + +const page = defineComponent(template, { + context: () => { + const loaded = sref(false) + const error = sref('') + const advanced = sref(false) + const restartRequired = sref(false) + const schema = sref(null) + const form = sref(null) + const active = sref(location.hash.slice(1) || 'general') + const selectedPreset = sref('') + let saved: DashboardSettings | null = null + + const sections = computed(() => + (schema()?.sections ?? []).filter( + (section) => !excluded.has(section.key), + ), + ) + const presets = computed(() => + (schema()?.presets ?? []).filter( + (preset) => preset.group === 'collection', + ), + ) + const show = (settings: DashboardSettings) => { + saved = settings + const document = cloneSettings(settings) + form(new SettingsForm(document, sections(), true)) + selectedPreset( + presets().find((preset) => presetMatches(document, preset))?.key ?? '', + ) + loaded(true) + } + const flow = createSaveFlow((next, result) => { + restartRequired(result.restartRequired) + show(next) + }) + void loadAdmin() + .then((context) => { + schema(context.schema) + restartRequired(context.restartRequired) + if (!sections().some((section) => section.key === active())) + active('general') + show(context.settings) + }) + .catch((failure: Error) => error(failure.message)) + + return { + loaded, + error, + advanced, + restartRequired, + form, + active, + sections, + presets, + selectedPreset, + flow, + select: (section: SettingSection) => { + active(section.key) + history.replaceState(null, '', `#${section.key}`) + }, + choose: (preset: SettingsPreset) => { + const current = form() as SettingsForm + applyPreset(current.collect(), preset) + current.refresh() + selectedPreset(preset.key) + }, + save: () => flow.review(() => (form() as SettingsForm).collect()), + discard: () => { + if (saved) show(saved) + flow.cancel() + }, + } + }, +}) + +mountPage({ + selector: '#admin-settings-app', + component: page, + components: { + ...defineSettingsFieldComponents(), + SaveReview: defineSaveReview(), + }, +}) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.mdx new file mode 100644 index 0000000..3adbdde --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.mdx @@ -0,0 +1,12 @@ +--- +title: Sites +description: Websites, domains and tracking snippets. +template: dashboard +section: admin-sites +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.ts new file mode 100644 index 0000000..432fdd6 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/sites/sites.ts @@ -0,0 +1,102 @@ +import { defineComponent, html, sref } from 'regor' +import { + createSitesEditor, + defineSitesEditor, + trackingSnippet, +} from '../../components/sites-editor' +import { createSaveFlow, defineSaveReview, loadAdmin } from '../../lib/admin' +import type { DashboardSettings } from '../../lib/admin-contracts' +import { mountPage } from '../../lib/page' +import { cloneSettings } from '../../lib/settings-model' + +const template = html`
+
+
ADMINISTRATION
+

Sites

+

The websites this server collects analytics for, and the domains their pages run on.

+
+
+{{ error }} + +

Websites

+

Removing a site hides it from reports and users; its stored data stays until retention removes it.

+ + + +
+ Review changes +
+
+ +
+ +

Tracking snippet

+

Add this script to every page of your sites. The server assigns each page view to the site whose domain matches.

+
+ {{ snippet }} + {{ copied ? 'Copied' : 'Copy' }} +
+

Record custom events with zta('signup', { plan: 'pro' }). Add data-hash-routing="true" for hash-based single-page apps.

+
` + +const page = defineComponent(template, { + context: () => { + const editor = createSitesEditor([], '', 'PrimarySite', true) + const trackUsage = sref(false) + const loaded = sref(false) + const error = sref('') + const copied = sref(false) + let settings: DashboardSettings | null = null + const show = (next: DashboardSettings) => { + settings = next + editor.load( + next.sites.items, + next.sites.primarySite, + next.sites.unmatchedTraffic, + ) + trackUsage(next.sites.trackDashboardUsage) + loaded(true) + } + const flow = createSaveFlow((saved) => show(saved)) + void loadAdmin() + .then((context) => show(context.settings)) + .catch((failure: Error) => error(failure.message)) + return { + editor, + trackUsage, + loaded, + error, + copied, + flow, + snippet: trackingSnippet(location.origin), + save: () => + flow.review(() => { + const next = cloneSettings(settings as DashboardSettings) + Object.assign(next.sites, editor.collect(), { + trackDashboardUsage: trackUsage(), + }) + return next + }), + copy: async () => { + try { + await navigator.clipboard.writeText(trackingSnippet(location.origin)) + copied(true) + } catch { + copied(false) + } + }, + } + }, +}) + +mountPage({ + selector: '#admin-sites-app', + component: page, + components: { + SitesEditor: defineSitesEditor(), + SaveReview: defineSaveReview(), + }, +}) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.mdx new file mode 100644 index 0000000..fcfa463 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.mdx @@ -0,0 +1,12 @@ +--- +title: Users +description: People who can sign in, their roles and sites. +template: dashboard +section: admin-users +layout: { showNav: false, showToc: false } +--- + + +
+ +
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts new file mode 100644 index 0000000..872c851 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts @@ -0,0 +1,297 @@ +import { defineComponent, html, type SRef, sref } from 'regor' +import { loadAdmin } from '../../lib/admin' +import type { SessionView, SiteView, UserView } from '../../lib/admin-contracts' +import { + describeAgent, + formatDate, + mountPage, + roleDescriptions, +} from '../../lib/page' +import { dashboardFetch } from '../../lib/session' + +interface SiteChoice { + id: string + name: string + checked: SRef +} + +// The create and edit form: one draft at a time. +interface UserDraft { + id: string | null + administrator: boolean + userName: SRef + displayName: SRef + role: SRef + allSites: SRef + disabled: SRef + password: SRef + sites: SiteChoice[] +} + +const roleOptions = [ + { value: 'Viewer', label: 'Viewer: reads reports' }, + { value: 'Analyst', label: 'Analyst: reports and query explorer' }, +] + +const template = html`
+
+
ADMINISTRATION
+

Users

+

Everyone who can sign in, what they can do, and which sites they see.

+
+
+ Add user +
+
+{{ error }} +{{ status }} + + +

{{ draft.id ? 'Edit ' + draft.userName : 'Add a user' }}

+

Share the user name and password with the person; they can change the password after signing in.

+ +
+ + + + +
+
+ Sites + +
+ +
+
+ +

Changing the role, sites, password or status signs the user out everywhere.

+
+ {{ draft.id ? 'Save user' : 'Create user' }} + Cancel + Delete user +
+
+
+

Signed-in browsers

+
    +
  • +
    + {{ describeAgent(session.userAgent) }} + You, now + {{ session.ipAddress || 'Unknown address' }} · last active {{ formatDate(session.lastSeen) }} +
    + Sign out +
  • +
+

No active sessions.

+ Sign out everywhere +
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
UserRoleSitesLast sign-inActions
+ {{ user.displayName }} + {{ user.userName }}{{ user.disabled ? ' · disabled' : '' }} + {{ user.role }}{{ siteSummary(user) }}{{ formatDate(user.lastSignIn) }}Edit
+
+
` + +const page = defineComponent(template, { + context: () => { + const users = sref([]) + const sites = sref([]) + const sessions = sref([]) + const draft = sref(null) + const error = sref('') + const status = sref('') + const busy = sref(false) + + const refresh = async () => + users(await dashboardFetch('/admin/users')) + const loadSessions = async (id: string) => + sessions( + await dashboardFetch( + `/admin/users/${encodeURIComponent(id)}/sessions`, + ), + ) + const open = (user: UserView | null) => { + error('') + status('') + const selected = new Set(user?.sites ?? []) + draft({ + id: user?.id ?? null, + administrator: user?.role === 'Administrator', + userName: sref(user?.userName ?? ''), + displayName: sref(user?.displayName ?? ''), + role: sref(user?.role ?? 'Viewer'), + allSites: sref(user?.allSites ?? false), + disabled: sref(user?.disabled ?? false), + password: sref(''), + sites: sites().map((site) => ({ + ...site, + checked: sref(selected.has(site.id)), + })), + }) + sessions([]) + if (user) + void loadSessions(user.id).catch((failure: Error) => + error(failure.message), + ) + } + const guard = async (action: () => Promise) => { + error('') + status('') + busy(true) + try { + await action() + } catch (failure) { + error((failure as Error).message) + } finally { + busy(false) + } + } + + void loadAdmin() + .then(async (context) => { + sites( + context.settings.sites.items.map((site) => ({ + id: site.id, + name: site.name, + })), + ) + await refresh() + }) + .catch((failure: Error) => error(failure.message)) + + return { + users, + sessions, + draft, + error, + status, + busy, + roleOptions, + roleDescriptions, + formatDate, + describeAgent, + siteSummary: (user: UserView) => + user.allSites + ? 'All sites' + : user.sites + .map((id) => sites().find((site) => site.id === id)?.name ?? id) + .join(', ') || 'None', + create: () => open(null), + edit: (user: UserView) => { + open(user) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, + close: () => draft(null), + save: () => + guard(async () => { + const current = draft() as UserDraft + const body = { + userName: current.userName().trim(), + displayName: current.displayName().trim(), + role: current.administrator ? null : current.role(), + allSites: current.administrator ? null : current.allSites(), + sites: current.administrator + ? null + : current.sites + .filter((site) => site.checked()) + .map((site) => site.id), + disabled: + current.id && !current.administrator ? current.disabled() : null, + password: current.password() ? current.password() : null, + } + const saved = current.id + ? await dashboardFetch( + `/admin/users/${encodeURIComponent(current.id)}`, + { method: 'PUT', body }, + ) + : await dashboardFetch('/admin/users', { body }) + await refresh() + draft(null) + status( + current.id + ? `Saved ${saved.userName}.` + : `Created ${saved.userName}. Share the password with them securely.`, + ) + }), + remove: () => + guard(async () => { + const current = draft() as UserDraft + if ( + !current.id || + !window.confirm( + `Delete ${current.userName()}? They are signed out at once.`, + ) + ) + return + await dashboardFetch( + `/admin/users/${encodeURIComponent(current.id)}`, + { method: 'DELETE' }, + ) + await refresh() + draft(null) + status(`Deleted ${current.userName()}.`) + }), + endSession: (session: SessionView) => + guard(async () => { + await dashboardFetch( + `/admin/sessions/${encodeURIComponent(session.id)}`, + { method: 'DELETE' }, + ) + await loadSessions((draft() as UserDraft).id as string) + }), + endAll: () => + guard(async () => { + const id = (draft() as UserDraft).id as string + await dashboardFetch( + `/admin/users/${encodeURIComponent(id)}/sessions`, + { method: 'DELETE' }, + ) + await loadSessions(id) + status('Signed out everywhere.') + }), + } + }, +}) + +mountPage({ selector: '#admin-users-app', component: page }) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/components/report-components.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/components/report-components.ts index ea7bbd0..4a6f61f 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/content/components/report-components.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/components/report-components.ts @@ -71,7 +71,7 @@ const reportHeader = html`
{{ dashboard.config.mode === 'demo' ? 'Demo data' : 'Live API' }}
- + {{ dashboard.realtime === null ? '—' : dashboard.number(dashboard.realtime) }} visitors now @@ -82,7 +82,7 @@ const reportHeader = html`
icon="tabler:download" :disabled="dashboard.loading || !dashboard.exportRows.length" @click="dashboard.exportCsv" - r-if="dashboard.kind !== 'settings' && dashboard.kind !== 'sample-data'" + r-if="dashboard.kind !== 'sample-data'" > Export diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/components/settings-fields.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/components/settings-fields.ts new file mode 100644 index 0000000..c35130e --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/components/settings-fields.ts @@ -0,0 +1,106 @@ +import { + type ComputedRef, + computed, + defineComponent, + html, + type RefOrValue, + unref, +} from 'regor' +import type { SettingSection } from '../lib/admin-contracts' +import type { FieldState, FormEntry, SettingsForm } from '../lib/settings-model' + +export interface SettingsFieldView { + state: RefOrValue +} + +export interface SettingsSectionView { + form: RefOrValue + section: RefOrValue + advanced: RefOrValue + showTiers?: RefOrValue + entries: ComputedRef +} + +// One schema-described setting. Values are bound as text and props, never HTML. +const fieldTemplate = html`
+
+ + {{ state.label }} + {{ state.tierLabel }} +
+ +
+ +
+
+ + {{ state.field.unit }} +
+ + + + + {{ state.hint }} +
` + +const sectionTemplate = html`
+ +
` + +export function defineSettingsFieldComponents() { + return { + SettingsField: defineComponent< + SettingsFieldView & { showTiers?: RefOrValue } + >(fieldTemplate, { + props: ['state', 'showTiers'], + }), + SettingsSection: defineComponent(sectionTemplate, { + props: ['form', 'section', 'advanced', 'showTiers'], + context: (head) => { + const props = head.props as SettingsSectionView + return { + ...props, + entries: computed(() => { + const form = unref(props.form) as SettingsForm + const advanced = unref(props.advanced) + return form + .entries(unref(props.section) as SettingSection) + .filter( + (entry) => + advanced || + !(entry.kind === 'group' + ? entry.advanced + : entry.field.advanced), + ) + }), + } + }, + }), + } +} diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/components/sites-editor.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/components/sites-editor.ts new file mode 100644 index 0000000..0202ae4 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/components/sites-editor.ts @@ -0,0 +1,208 @@ +import { + type ComputedRef, + computed, + defineComponent, + html, + type RefOrValue, + type SRef, + sref, +} from 'regor' +import type { SiteSettings } from '../lib/admin-contracts' +import { splitList } from '../lib/settings-model' + +export interface SiteDraft { + name: SRef + id: SRef + domains: SRef + // Existing sites keep their ID: their data is stored under it. + locked: boolean + idTouched: boolean +} + +export interface SitesEditor { + sites: SRef + primarySite: SRef + unmatched: SRef + primaryOptions: ComputedRef<{ value: string; label: string }[]> + unmatchedOptions: { value: string; label: string }[] + add: () => void + remove: (site: SiteDraft) => void + nameChanged: (site: SiteDraft) => void + idChanged: (site: SiteDraft) => void + collect: () => { + items: SiteSettings[] + primarySite: string + unmatchedTraffic: string + } + load: ( + items: SiteSettings[], + primarySite: string, + unmatchedTraffic: string, + ) => void +} + +export function siteSlug(name: string): string { + return ( + name + .toLowerCase() + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) || 'site' + ) +} + +export function createSitesEditor( + items: SiteSettings[], + primarySite: string, + unmatchedTraffic: string, + lockExisting: boolean, +): SitesEditor { + const draft = (site: SiteSettings, locked: boolean): SiteDraft => ({ + name: sref(site.name), + id: sref(site.id), + domains: sref(site.domains.join(', ')), + locked, + idTouched: locked, + }) + const sites = sref(items.map((site) => draft(site, lockExisting))) + const primary = sref(primarySite) + const unmatched = sref(unmatchedTraffic) + const ensurePrimary = () => { + if (!sites().some((site) => site.id() === primary())) + primary(sites()[0]?.id() ?? '') + } + const uniqueId = (base: string, except: SiteDraft) => { + let id = base + for ( + let n = 2; + sites().some((site) => site !== except && site.id() === id); + n++ + ) + id = `${base}-${n}` + return id + } + return { + sites, + primarySite: primary, + unmatched, + primaryOptions: computed(() => + sites().map((site) => ({ + value: site.id(), + label: site.name() || site.id(), + })), + ), + unmatchedOptions: [ + { value: 'PrimarySite', label: 'Record them in the primary site' }, + { value: 'Ignore', label: 'Ignore them' }, + ], + add: () => { + const site = draft({ id: '', name: '', domains: [] }, false) + site.id(uniqueId('site', site)) + sites([...sites(), site]) + ensurePrimary() + }, + remove: (site) => { + if (sites().length < 2) return + sites(sites().filter((item) => item !== site)) + ensurePrimary() + }, + nameChanged: (site) => { + if (site.idTouched) return + const renamesPrimary = primary() === site.id() + site.id(uniqueId(siteSlug(site.name()), site)) + if (renamesPrimary) primary(site.id()) + ensurePrimary() + }, + idChanged: (site) => { + site.idTouched = true + ensurePrimary() + }, + load: (next, nextPrimary, nextUnmatched) => { + sites(next.map((site) => draft(site, lockExisting))) + primary(nextPrimary) + unmatched(nextUnmatched) + ensurePrimary() + }, + collect: () => { + const result = sites().map((site) => ({ + id: site.id().trim(), + name: site.name().trim(), + domains: splitList(site.domains()), + })) + if (!result.length) throw new Error('Add at least one site.') + for (const site of result) { + if (!site.name) throw new Error('Give every site a name.') + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(site.id)) + throw new Error( + `Site ID "${site.id}" must be lowercase letters, digits, dots, dashes or underscores.`, + ) + } + if (new Set(result.map((site) => site.id)).size !== result.length) + throw new Error('Each site needs its own ID.') + if (!result.some((site) => site.id === primary())) primary(result[0].id) + return { + items: result, + primarySite: primary(), + unmatchedTraffic: unmatched(), + } + }, + } +} + +const template = html`
+
+ + + + +
+

+ Pages on these domains report to the site. *.example.com covers example.com and its subdomains. + The site ID names the site in reports and the API; it cannot change once data is stored. +

+ Add site +
+ + +
+
` + +export function defineSitesEditor() { + return defineComponent<{ editor: RefOrValue }>(template, { + props: ['editor'], + }) +} + +export function trackingSnippet(origin: string): string { + return `` +} diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.mdx deleted file mode 100644 index 71f95d6..0000000 --- a/apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Connection -description: Configure your reporting API connection. -template: dashboard -section: settings -layout: { showNav: false, showToc: false } ---- - - -
- -
diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.ts deleted file mode 100644 index 14f3e9a..0000000 --- a/apps/ZoneTree.Analytics.Dashboard/ui/content/connection/connection.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { defineComponent, html, sref } from 'regor' -import { ApiClient } from '../lib/api' -import { - loadConfig, - parseSites, - validateConfig, - writeStorage, -} from '../lib/config' -import type { Config, SummaryReport } from '../lib/contracts' -import type { DashboardModel } from '../lib/dashboard-model' -import { mountReport } from '../lib/mount' - -export function createConnectionModel(model: DashboardModel) { - const mode = sref(model.config().mode) - const apiBaseUrl = sref(model.config().apiBaseUrl) - const timeZone = sref(model.config().timeZone) - const refreshSeconds = sref(model.config().refreshSeconds) - const sites = sref( - model - .config() - .sites.map((site) => `${site.id} | ${site.name}`) - .join('\n'), - ) - const token = sref(model.api.token) - const status = sref('') - const testing = sref(false) - let testController: AbortController | null = null - model.registerCleanup(() => testController?.abort()) - const candidate = (): Config => - validateConfig({ - ...model.config(), - mode: mode(), - apiBaseUrl: apiBaseUrl(), - timeZone: timeZone(), - refreshSeconds: Number(refreshSeconds()), - sites: parseSites(sites()), - }) - function apply(value: Config) { - mode(value.mode) - apiBaseUrl(value.apiBaseUrl) - timeZone(value.timeZone) - refreshSeconds(value.refreshSeconds) - sites(value.sites.map((site) => `${site.id} | ${site.name}`).join('\n')) - model.updateConfig(value) - } - return { - mode, - apiBaseUrl, - timeZone, - refreshSeconds, - sites, - token, - status, - testing, - modes: [ - { value: 'demo', label: 'Demo · synthetic data' }, - { value: 'live', label: 'Live reporting API' }, - ], - refreshOptions: [ - { value: 0, label: 'Off' }, - { value: 30, label: 'Every 30 seconds' }, - { value: 60, label: 'Every minute' }, - { value: 300, label: 'Every 5 minutes' }, - ], - save: (event: Event) => { - event.preventDefault() - model.error('') - status('') - try { - const value = candidate() - writeStorage('zt-dashboard-connection-v1', value) - writeStorage( - 'zt-dashboard-token-v1', - { base: value.apiBaseUrl, token: token().trim() }, - true, - ) - model.api.token = token().trim() - apply(value) - status('Connection saved. Open a report from the sidebar to use it.') - } catch (failure) { - model.error((failure as Error).message) - } - }, - test: async () => { - status('') - model.error('') - testing(true) - testController?.abort() - testController = new AbortController() - try { - const value = candidate() - const client = new ApiClient(value) - client.token = token().trim() - const report = await client.request( - 'summary', - { - site: value.sites[0].id, - range: 'today', - compare: 'none', - tz: value.timeZone, - filters: [], - }, - testController.signal, - ) - status( - `API connected. Today's report has ${model.number(report.summary.visitors)} visitors for ${value.sites[0].name}.`, - ) - } catch (failure) { - model.error((failure as Error).message) - } finally { - testing(false) - } - }, - reset: async () => { - try { - writeStorage('zt-dashboard-connection-v1', null) - writeStorage('zt-dashboard-token-v1', null, true) - model.api.token = '' - token('') - apply(await loadConfig()) - model.error('') - status('Restored the configuration supplied by your host.') - } catch (failure) { - model.error((failure as Error).message) - } - }, - } -} - -export interface ConnectionPage { - dashboard: DashboardModel - connection: ReturnType -} - -const connectionTemplate = html` - - - -

Your analytics connection

-

- Choose your data source and reporting settings, then test and save your connection. -

- -
- - - - - -
- -

- Stored in sessionStorage for this tab so it survives page navigation. Cleared when the - tab closes. Never stored in a URL or localStorage. -

-
-
-
- - Save connection - - - Test API connection - - - Reset settings - -
- {{ connection.status }} - Testing the reporting API… -
-
- -` - -export function defineConnectionPage(model: DashboardModel) { - return defineComponent(connectionTemplate, { - context: () => ({ - dashboard: model, - connection: createConnectionModel(model), - }), - }) -} - -void mountReport({ - kind: 'settings', - selector: '#connection-app', - component: defineConnectionPage, -}) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/header.mdx b/apps/ZoneTree.Analytics.Dashboard/ui/content/header.mdx index d494945..05a9a83 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/content/header.mdx +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/header.mdx @@ -62,8 +62,8 @@ >Bot traffic - TOOLS -
`, { props: ['title'] }, ), + // The brand column of the sign-in, sign-out and setup pages. + AuthAside: defineComponent( + html` +
`, + ), } } diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/design/plugin.ts b/apps/ZoneTree.Analytics.Dashboard/ui/design/plugin.ts index 19f24a5..9addf5b 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/design/plugin.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/design/plugin.ts @@ -14,6 +14,13 @@ import { dashboardSkin } from './skin' import { registerDashboardStyles } from './styles' let trackerSourcePath = '' +const previewUser = { + id: 'preview', + userName: 'preview', + displayName: 'Preview administrator', + role: 'Administrator', + canQuery: true, +} // A dedicated application template, following the reference site's plugin model. // All routes, assets and PageScript bundles are still generated by PureStack. @@ -38,6 +45,38 @@ export const dashboardPlugin = definePlugin({ response.writeHead(204).end() return } + // Standalone previews act as a signed-in administrator; the dashboard API needs the server. + if (pathname === '/_dashboard/api/auth/me' && request.method === 'GET') { + response + .writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }) + .end( + JSON.stringify({ + user: { + ...previewUser, + allSites: true, + sites: [], + disabled: false, + created: new Date(0).toISOString(), + lastSignIn: null, + }, + sites: defaultConfig.sites, + sessionId: 'preview', + }), + ) + return + } + if (pathname.startsWith('/_dashboard/api/')) { + response.writeHead(404, { 'Content-Type': 'application/json' }).end( + JSON.stringify({ + error: + 'This needs the ZoneTree Analytics server. The standalone preview has no dashboard API.', + }), + ) + return + } if (pathname !== dashboardConfigPath) return if (request.method !== 'GET') { response.writeHead(405, { Allow: 'GET' }).end() @@ -48,7 +87,13 @@ export const dashboardPlugin = definePlugin({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }) - .end(JSON.stringify({ ...defaultConfig, timeZone: 'UTC' })) + .end( + JSON.stringify({ + ...defaultConfig, + timeZone: 'UTC', + user: previewUser, + }), + ) }, hooks: { onConfigResolved: (context) => { @@ -72,6 +117,27 @@ export const dashboardPlugin = definePlugin({ }, }, templates: { + // Sign-in, sign-out and setup: no navigation and no tracker. + auth: ({ head, bodyHtml, site }) => { + head.push( + h('meta').attr({ name: 'robots', content: 'noindex, nofollow' }), + ) + return h('html') + .attr({ lang: 'en', 'data-theme': 'light' }) + .push( + head, + h('body') + .class('zt-dashboard zt-auth') + .attr({ 'data-base-path': site.basePath || '/' }) + .push( + h('a') + .class('skip-link') + .attr({ href: '#main' }) + .text('Skip to content'), + h('div').class('auth-shell').raw(bodyHtml), + ), + ) + }, dashboard: ({ head, bodyHtml, headerHtml, site, pageInfo }) => { head.push( h('script').attr({ diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles.ts b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles.ts index 0c31e1e..554446c 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles.ts @@ -1,6 +1,7 @@ import { styleBuilder, themes } from '@purestack/ts-style' import { registerDashboardLogoStyles } from './logo' import { registerAccessibilityStyles } from './styles/accessibility' +import { registerAdminStyles } from './styles/admin' import { registerBaseStyles } from './styles/base' import { registerChartsStyles } from './styles/charts' import { createDashboardStyleContext } from './styles/context' @@ -30,6 +31,7 @@ export function registerDashboardStyles() { registerTablesStyles(context) registerFormsStyles(context) registerDialogsStyles(context) + registerAdminStyles(context) // Visibility utilities override control display; breakpoints override them. registerAccessibilityStyles(context) registerResponsiveStyles(context) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts new file mode 100644 index 0000000..8e42fd4 --- /dev/null +++ b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts @@ -0,0 +1,507 @@ +import { sidebarColors as sidebar } from '../colors' +import type { DashboardStyleContext } from './context' + +// Sign-in, setup, account and administration pages. +export function registerAdminStyles(context: DashboardStyleContext) { + registerRoleStyles(context) + registerAuthStyles(context) + registerWizardStyles(context) + registerSettingsStyles(context) + registerAdminPageStyles(context) + registerAdminResponsiveStyles(context) +} + +// Navigation for roles appears once the session is known; the server enforces access anyway. +function registerRoleStyles({ root }: DashboardStyleContext) { + root.select('.admin-only, .query-only').css({ display: 'none' }) + // The theme stylesheet is scoped to , which carries the role classes. + root + .select(':scope.role-admin .admin-only, :scope.role-query .query-only') + .css({ display: 'revert' }) + root + .select( + ':scope.role-admin .nav-list.admin-only, :scope.role-query .nav-list.query-only', + ) + .css({ display: 'grid' }) + root + .select(':scope.role-admin .nav-list a.admin-only') + .css({ display: 'flex' }) + root + .select(':scope.role-admin .sign-in__panel a.admin-only') + .css({ display: 'inline-flex' }) + root.select('.topbar-account').css({ lineHeight: '0' }) + root.select('[data-user-initials]:empty::before').css({ content: "'•'" }) +} + +function registerAuthStyles({ + root, + ink, + muted, + edge, + surface, + colors, +}: DashboardStyleContext) { + root.select('body.zt-auth').css({ background: colors.canvas }) + root.select('.auth-shell').css({ + display: 'grid', + gridTemplateColumns: 'minmax(300px, 420px) minmax(0, 1fr)', + minHeight: '100dvh', + }) + root.select('.auth-aside').css({ + background: sidebar.canvas, + color: sidebar.ink, + padding: '40px 36px', + display: 'flex', + flexDirection: 'column', + gap: '48px', + position: 'sticky', + top: '0', + height: '100dvh', + }) + root.select('.auth-pitch').css({ marginTop: 'auto' }) + root.select('.auth-pitch h2').css({ + fontSize: '28px', + lineHeight: '1.2', + marginBottom: '12px', + color: sidebar.ink, + }) + root.select('.auth-pitch p').css({ + color: sidebar.link, + fontSize: '13px', + lineHeight: '1.6', + maxWidth: '300px', + }) + // The auth template has no topbar: the theme toggle sits in the corner on its own surface. + root.select('.auth-actions').css({ + position: 'fixed', + top: '16px', + right: '16px', + zIndex: '20', + display: 'flex', + background: surface, + border: `1px solid ${edge}`, + borderRadius: '999px', + boxShadow: colors.shadow, + }) + root.select('.auth-main').css({ + display: 'grid', + placeItems: 'center', + padding: '48px 24px', + minWidth: '0', + }) + root.select('.auth-card').css({ + width: 'min(440px, 100%)', + background: surface, + border: `1px solid ${edge}`, + borderRadius: '14px', + padding: '32px', + boxShadow: colors.shadow, + color: ink, + }) + root.select('.auth-main-wide .auth-card').css({ width: 'min(760px, 100%)' }) + root.select('.auth-heading').css({ marginBottom: '24px' }) + root.select('.auth-heading h1').css({ fontSize: '24px', marginBottom: '8px' }) + root + .select('.auth-heading p') + .css({ color: muted, fontSize: '13px', lineHeight: '1.6' }) + root.select('.zt-dashboard .auth-form').css({ display: 'grid', gap: '16px' }) + root + .select('.zt-dashboard .auth-form .form-block__field') + .css({ margin: '0' }) + root.select('.auth-submit').css({ width: '100%', justifyContent: 'center' }) + root + .select('.auth-footnote') + .css({ marginTop: '24px', fontSize: '12px', color: muted }) + root.select('.zt-dashboard .auth-form .auth-submit').css({ marginTop: '4px' }) +} + +function registerWizardStyles({ + root, + ink, + muted, + edge, + surface, + accent, + colors, +}: DashboardStyleContext) { + root + .select('.setup-wizard') + .css({ width: 'min(760px, 100%)', display: 'grid', gap: '20px' }) + root.select('.setup-card').css({ width: '100%' }) + root.select('.setup-steps').css({ + display: 'flex', + flexWrap: 'wrap', + gap: '6px 18px', + listStyle: 'none', + padding: '0', + margin: '0', + fontSize: '12px', + color: muted, + }) + root + .select('.setup-steps li') + .css({ display: 'flex', alignItems: 'center', gap: '8px' }) + root.select('.setup-step-number').css({ + width: '22px', + height: '22px', + borderRadius: '50%', + display: 'grid', + placeItems: 'center', + border: `1px solid ${edge}`, + background: surface, + fontSize: '11px', + fontWeight: '600', + }) + root.select('.setup-steps li.current').css({ color: ink, fontWeight: '600' }) + root + .select('.setup-steps li.current .setup-step-number') + .css({ background: accent, borderColor: accent, color: colors.accentInk }) + root + .select('.setup-steps li.complete .setup-step-number') + .css({ background: colors.accentSoft, color: accent }) + root.select('.preset-grid').css({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', + gap: '12px', + margin: '4px 0 18px', + }) + root.select('.preset-card').css({ + display: 'grid', + gap: '6px', + textAlign: 'left', + padding: '16px', + borderRadius: '10px', + border: `1px solid ${edge}`, + background: surface, + color: ink, + cursor: 'pointer', + font: 'inherit', + }) + root.select('.preset-card:hover').css({ borderColor: accent }) + root.select('.preset-card.selected').css({ + borderColor: accent, + background: colors.accentSoft, + boxShadow: `inset 0 0 0 1px ${accent}`, + }) + root.select('.preset-card strong').css({ fontSize: '14px' }) + root + .select('.preset-card span') + .css({ fontSize: '12px', color: muted, lineHeight: '1.5' }) + root + .select('.setup-summary') + .css({ display: 'grid', gap: '10px', margin: '0 0 20px' }) + root.select('.setup-summary div, .detail-list div').css({ + display: 'grid', + gridTemplateColumns: '160px minmax(0, 1fr)', + gap: '12px', + fontSize: '13px', + }) + root.select('.setup-summary dt, .detail-list dt').css({ color: muted }) + root + .select('.setup-summary dd, .detail-list dd') + .css({ margin: '0', color: ink, overflowWrap: 'anywhere' }) + root + .select('.detail-list dd small') + .css({ display: 'block', color: muted, marginTop: '3px', fontSize: '11px' }) + root.select('.detail-list').css({ display: 'grid', gap: '12px', margin: '0' }) + root.select('.advanced-settings').css({ + borderTop: `1px solid ${edge}`, + paddingTop: '16px', + marginBottom: '16px', + }) + root.select('.advanced-settings > summary').css({ + cursor: 'pointer', + fontWeight: '600', + color: ink, + marginBottom: '12px', + }) + root.select('.snippet').css({ + display: 'flex', + gap: '12px', + alignItems: 'center', + padding: '12px 14px', + borderRadius: '8px', + border: `1px solid ${edge}`, + background: colors.surfaceAlt, + marginBottom: '8px', + }) + root + .select('.snippet code') + .css({ flex: '1', overflowWrap: 'anywhere', fontSize: '12px' }) +} + +function registerSettingsStyles({ + root, + ink, + muted, + edge, + accent, + colors, +}: DashboardStyleContext) { + root.select('.settings-grid').css({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: '18px 22px', + marginBottom: '8px', + }) + root.select('.settings-group').css({ + gridColumn: '1 / -1', + fontSize: '13px', + fontWeight: '600', + color: ink, + margin: '14px 0 2px', + paddingTop: '14px', + borderTop: `1px solid ${edge}`, + }) + root + .select('.setting-field') + .css({ display: 'grid', gap: '7px', alignContent: 'start', minWidth: '0' }) + root.select('.setting-label').css({ + display: 'flex', + justifyContent: 'space-between', + gap: '8px', + fontSize: '12px', + color: muted, + }) + root.select('.setting-tier').css({ + fontSize: '10px', + padding: '1px 7px', + borderRadius: '999px', + whiteSpace: 'nowrap', + background: colors.hover, + color: muted, + alignSelf: 'start', + }) + root + .select('.tier-Reopen') + .css({ background: colors.infoSoft, color: colors.info }) + root + .select('.tier-Restart') + .css({ background: colors.warningSoft, color: colors.warning }) + root + .select('.tier-Fixed') + .css({ background: colors.dangerSoft, color: colors.danger }) + root + .select('.setting-input') + .css({ display: 'flex', alignItems: 'center', gap: '8px' }) + root + .select('.setting-unit') + .css({ fontSize: '12px', color: muted, whiteSpace: 'nowrap' }) + root.select('.zt-dashboard .setting-field select').css({ + border: `1px solid ${edge}`, + borderRadius: '6px', + padding: '9px 11px', + background: colors.surface, + color: ink, + width: '100%', + minWidth: '0', + }) + root.select('.zt-dashboard label.check').css({ + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '12px', + color: ink, + cursor: 'pointer', + }) + root + .select('.zt-dashboard label.check input') + .css({ width: 'auto', padding: '0', border: '0', accentColor: accent }) + root + .select('.setting-choices') + .css({ display: 'flex', flexWrap: 'wrap', gap: '8px 16px' }) + root.select('.setting-locked').css({ opacity: '0.75' }) + root.select('.advanced-toggle').css({ margin: '4px 0 16px' }) + root + .select('.settings-help') + .css({ fontSize: '12px', color: muted, marginBottom: '14px' }) + root.select('.settings-section').css({ marginTop: '22px' }) + root + .select('.settings-section h2') + .css({ fontSize: '16px', marginBottom: '4px' }) + root.select('.settings-layout').css({ + display: 'grid', + gridTemplateColumns: '200px minmax(0, 1fr)', + gap: '24px', + alignItems: 'start', + }) + root + .select('.settings-nav') + .css({ display: 'grid', gap: '2px', position: 'sticky', top: '24px' }) + root.select('.settings-nav button').css({ + textAlign: 'left', + padding: '8px 12px', + borderRadius: '6px', + border: '0', + background: 'transparent', + color: muted, + font: 'inherit', + fontSize: '13px', + cursor: 'pointer', + }) + root + .select('.settings-nav button:hover') + .css({ background: colors.hover, color: ink }) + root + .select('.settings-nav button.active') + .css({ background: colors.accentSoft, color: accent, fontWeight: '600' }) + root.select('.sticky-actions').css({ + position: 'sticky', + bottom: '0', + display: 'flex', + gap: '12px', + padding: '14px 0', + background: colors.canvas, + zIndex: '5', + }) + root.select('.save-review').css({ borderColor: accent }) + root.select('.change-list').css({ + listStyle: 'none', + padding: '0', + margin: '0 0 16px', + display: 'grid', + gap: '8px', + }) + root.select('.change-list li').css({ + display: 'flex', + justifyContent: 'space-between', + gap: '12px', + fontSize: '13px', + }) +} + +function registerAdminPageStyles({ + root, + ink, + muted, + edge, + colors, +}: DashboardStyleContext) { + root.select('.admin-columns').css({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: '22px', + alignItems: 'start', + }) + root.select('.status-cards').css({ + display: 'grid', + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + gap: '16px', + marginBottom: '22px', + }) + root + .select('.status-card .panel__body') + .css({ display: 'grid', gap: '6px', padding: '18px' }) + root.select('.status-card span').css({ fontSize: '12px', color: muted }) + root.select('.status-card strong').css({ + fontSize: '22px', + color: ink, + fontWeight: '600', + fontVariantNumeric: 'tabular-nums', + }) + root.select('.status-card small').css({ fontSize: '11px', color: muted }) + root.select('.session-list').css({ + listStyle: 'none', + padding: '0', + margin: '0 0 12px', + display: 'grid', + gap: '2px', + }) + root.select('.session-list li').css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '12px', + padding: '10px 0', + borderBottom: `1px solid ${edge}`, + fontSize: '13px', + }) + root + .select('.session-list small') + .css({ display: 'block', color: muted, fontSize: '11px', marginTop: '3px' }) + root.select('.session-list .badge').css({ marginLeft: '8px' }) + root.select('.user-sessions').css({ + marginTop: '22px', + paddingTop: '18px', + borderTop: `1px solid ${edge}`, + }) + root + .select('.user-sessions h3') + .css({ fontSize: '14px', marginBottom: '8px' }) + root + .select('.users-table td small') + .css({ display: 'block', color: muted, fontSize: '11px' }) + root + .select('.users-table tr.disabled td, .audit-table tr.warning td') + .css({ color: muted }) + root + .select('.audit-table tr.warning td:nth-child(3)') + .css({ color: colors.danger }) + root.select('.site-access').css({ + border: `1px solid ${edge}`, + borderRadius: '8px', + padding: '12px 14px', + margin: '0 0 16px', + display: 'grid', + gap: '10px', + }) + root + .select('.site-access legend') + .css({ fontSize: '12px', color: muted, padding: '0 6px' }) + root.select('.sites-editor').css({ display: 'grid', gap: '12px' }) + root.select('.site-row, .row-editor-row').css({ + display: 'grid', + gridTemplateColumns: + 'minmax(0, 1fr) minmax(0, 0.8fr) minmax(0, 1.4fr) 34px', + gap: '10px', + alignItems: 'end', + }) + root + .select('.row-editor-row') + .css({ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr) 34px' }) + root + .select('.row-editor') + .css({ display: 'grid', gap: '10px', marginBottom: '12px' }) + root.select('.sites-options').css({ marginTop: '8px' }) + root.select('.button.danger').css({ color: colors.danger }) +} + +function registerAdminResponsiveStyles({ root }: DashboardStyleContext) { + const tablet = root.media('max-width: 1150px') + tablet + .select('.status-cards') + .css({ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }) + tablet.select('.admin-columns').css({ gridTemplateColumns: 'minmax(0, 1fr)' }) + const narrow = root.media('max-width: 850px') + narrow + .select('.auth-shell') + .css({ gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'auto 1fr' }) + narrow + .select('.auth-aside') + .css({ position: 'static', height: 'auto', padding: '24px', gap: '16px' }) + narrow + .select('.auth-pitch, .auth-aside .privacy-mark') + .css({ display: 'none' }) + narrow + .select('.auth-main') + .css({ padding: '24px 16px', placeItems: 'start center' }) + narrow.select('.auth-card').css({ padding: '22px' }) + narrow + .select('.settings-grid, .settings-layout') + .css({ gridTemplateColumns: 'minmax(0, 1fr)' }) + narrow + .select('.settings-nav') + .css({ position: 'static', display: 'flex', flexWrap: 'wrap' }) + narrow + .select('.site-row, .row-editor-row') + .css({ gridTemplateColumns: 'minmax(0, 1fr) 34px' }) + narrow + .select( + '.site-row label, .row-editor-row label, .row-editor-row .setting-choices', + ) + .css({ gridColumn: '1' }) + narrow.select('.status-cards').css({ gridTemplateColumns: 'minmax(0, 1fr)' }) + narrow + .select('.setup-summary div, .detail-list div') + .css({ gridTemplateColumns: 'minmax(0, 1fr)', gap: '2px' }) +} diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/forms.ts b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/forms.ts index 360da4e..401aaa9 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/forms.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/forms.ts @@ -21,7 +21,7 @@ export function registerFormsStyles(context: DashboardStyleContext) { .marginBottom('20px') root - .select('.zt-dashboard label:not(.control):not(.mobile-only)') + .select('.zt-dashboard label:not(.control):not(.check):not(.mobile-only)') .display('grid') .gap('7px') .color(muted) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/navigation.ts b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/navigation.ts index 3608c7c..eb02460 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/navigation.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/navigation.ts @@ -138,7 +138,12 @@ export function registerNavigationStyles(context: DashboardStyleContext) { 'bots', 'explorer', 'sample-data', - 'settings', + 'admin', + 'admin-sites', + 'admin-users', + 'admin-indexes', + 'admin-settings', + 'admin-audit', ]) { root .select(`body[data-section="${section}"] .nav-list .nav-${section}`) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/tests/browser.ts b/apps/ZoneTree.Analytics.Dashboard/ui/tests/browser.ts index ea35957..26050de 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/tests/browser.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/tests/browser.ts @@ -14,6 +14,14 @@ import { import { checkLargeSvgChart } from './chart-browser' import { checkSampleDataTools } from './sample-data-browser' +const testUser = { + id: 'test', + userName: 'tester', + displayName: 'Test Administrator', + role: 'Administrator', + canQuery: true, +} + test('dashboard browser reports, navigation, and sample tools', async () => { const workspace = fileURLToPath(new URL('../', import.meta.url)) const root = resolve( @@ -52,7 +60,36 @@ test('dashboard browser reports, navigation, and sample tools', async () => { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }) - .end(JSON.stringify({ ...defaultConfig, timeZone: 'UTC' })) + .end( + JSON.stringify({ + ...defaultConfig, + timeZone: 'UTC', + user: testUser, + }), + ) + return + } + if (path === '/_dashboard/api/auth/me') { + response.writeHead(200, { 'Content-Type': 'application/json' }).end( + JSON.stringify({ + user: { + ...testUser, + allSites: true, + sites: [], + disabled: false, + created: '2026-01-01T00:00:00Z', + lastSignIn: null, + }, + sites: defaultConfig.sites, + sessionId: 'test', + }), + ) + return + } + if (path === '/_dashboard/api/auth/me/sessions') { + response + .writeHead(200, { 'Content-Type': 'application/json' }) + .end('[]') return } if (path.endsWith('/')) path += 'index.html' @@ -95,7 +132,9 @@ test('dashboard browser reports, navigation, and sample tools', async () => { const page = await context.newPage() // Keep demo assertions deterministic when the full suite uses a live host. await page.route(`**${dashboardConfigPath}`, (route) => - route.fulfill({ json: { ...defaultConfig, timeZone: 'UTC' } }), + route.fulfill({ + json: { ...defaultConfig, timeZone: 'UTC', user: testUser }, + }), ) const errors: string[] = [] page.on('pageerror', (error) => errors.push(error.message)) @@ -311,12 +350,16 @@ test('dashboard browser reports, navigation, and sample tools', async () => { .getByRole('heading', { name: 'Conversion journey', exact: true }) .waitFor() assert.equal(await page.locator('.funnel-result').count(), 3) - await page.goto(`${base}/connection/`) - await ready() + // The signed-in administrator sees their initials, the administration navigation and their account. + assert.equal( + await page.locator('[data-user-initials]').first().textContent(), + 'TA', + ) + assert.ok(await page.locator('.nav-admin').isVisible()) + await page.goto(`${base}/account/`) await page - .getByRole('button', { name: 'Save connection', exact: true }) - .click() - await page.getByText('Connection saved.', { exact: false }).waitFor() + .getByRole('heading', { name: 'Signed-in browsers', exact: true }) + .waitFor() await page.goto(`${base}/`) await ready() await page.getByRole('button', { name: 'Dark theme', exact: true }).click() @@ -435,7 +478,6 @@ test('dashboard browser reports, navigation, and sample tools', async () => { // A failed live connection must never fall back to plausible demo numbers. await page.evaluate(() => { - localStorage.removeItem('zt-dashboard-connection-v1') sessionStorage.removeItem('zt-dashboard-scope-v1') }) await page.route(`**${dashboardConfigPath}`, (route) => @@ -660,7 +702,7 @@ test('dashboard browser reports, navigation, and sample tools', async () => { await checkSampleDataTools(page, base) assert.deepEqual(errors, [], 'No browser errors with sample tools') console.log( - 'Browser checks passed: nine routes, filters, charts, queries, funnels, connection, CSV, themes, mobile navigation, API failure/recovery, zero counts, safe text binding, pagination.', + 'Browser checks passed: nine routes, filters, charts, queries, funnels, account, CSV, themes, mobile navigation, API failure/recovery, zero counts, safe text binding, pagination.', ) } finally { await browser?.close() diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/tests/client.test.ts b/apps/ZoneTree.Analytics.Dashboard/ui/tests/client.test.ts index 7e9ed65..0e72c91 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/tests/client.test.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/tests/client.test.ts @@ -6,7 +6,7 @@ import { parameters, postScope, } from '../content/lib/api' -import { parseSites, validateConfig } from '../content/lib/config' +import { validateConfig } from '../content/lib/config' import { type Breakdown, defaultConfig, @@ -65,10 +65,6 @@ test('configuration validates endpoints, sites, timezone and refresh intervals', '/api?token=secret', ]) assert.throws(() => normalizeApiUrl(invalid)) - assert.deepEqual(parseSites(' | Default site\nstore | Store'), [ - { id: '', name: 'Default site' }, - { id: 'store', name: 'Store' }, - ]) assert.throws(() => validateConfig({ ...defaultConfig, diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/tests/dashboard-model.test.ts b/apps/ZoneTree.Analytics.Dashboard/ui/tests/dashboard-model.test.ts index 27140f6..fe3646f 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/tests/dashboard-model.test.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/tests/dashboard-model.test.ts @@ -34,7 +34,9 @@ function browserEnvironment(url = 'http://localhost/') { const document = { hidden: false, body: { dataset: { basePath: '/' } }, + documentElement: { classList: { toggle: () => true } }, getElementById: () => null, + querySelectorAll: () => [], } const history = { replaceState: ( diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/tests/setup.ts b/apps/ZoneTree.Analytics.Dashboard/ui/tests/setup.ts index 3dd3272..7b1438d 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/tests/setup.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/tests/setup.ts @@ -12,6 +12,8 @@ declare module 'vitest' { interface ProvidedContext { dashboardSiteRoot: string dashboardTestUrl: string + // The one-time key a fresh host prints until setup creates the administrator. + dashboardSetupKey: string } } @@ -61,6 +63,10 @@ export default async function setup(project: TestProject) { if (process.env.ZT_DASHBOARD_TEST_URL) { const url = new URL(process.env.ZT_DASHBOARD_TEST_URL) project.provide('dashboardTestUrl', url.href) + project.provide( + 'dashboardSetupKey', + process.env.ZT_DASHBOARD_SETUP_KEY ?? '', + ) return teardown } @@ -92,8 +98,6 @@ export default async function setup(project: TestProject) { '--Dashboard:Watch=false', '--Dashboard:Path', site, - '--Analytics:DataDirectory', - join(temporary, 'data/analytics'), '--Urls=http://127.0.0.1:0', '--Logging:LogLevel:Microsoft.Hosting.Lifetime=Information', ], @@ -127,8 +131,10 @@ export default async function setup(project: TestProject) { const url = output.match( /Now listening on: (http:\/\/127\.0\.0\.1:\d+)/, )?.[1] - if (url) { + const key = output.match(/Setup key: ([A-Z0-9-]+)/)?.[1] + if (url && key) { project.provide('dashboardTestUrl', `${url}/`) + project.provide('dashboardSetupKey', key) console.log(`Dashboard test host ready at ${url}`) return teardown } diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/tests/tracker.ts b/apps/ZoneTree.Analytics.Dashboard/ui/tests/tracker.ts index b7fde81..c9f7003 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/tests/tracker.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/tests/tracker.ts @@ -19,6 +19,29 @@ test('dashboard visits record accepted beacons in the live .NET engine', async ( channel: process.env.ZT_BROWSER_CHANNEL ?? 'chrome', headless: true, }) + // A fresh host starts in setup mode: create the administrator and record dashboard visits. + const verified = await http.post('/_dashboard/api/setup/verify', { + data: { key: inject('dashboardSetupKey') }, + }) + assert.ok( + verified.ok(), + 'The setup key from the host log must unlock setup', + ) + const { settings } = (await verified.json()) as { + settings: { sites: { trackDashboardUsage: boolean } } + } + settings.sites.trackDashboardUsage = true + const completed = await http.post('/_dashboard/api/setup/complete', { + data: { + key: inject('dashboardSetupKey'), + administrator: { + userName: 'tester', + password: 'tracker test password', + }, + settings, + }, + }) + assert.ok(completed.ok(), await completed.text()) const response = await http.get(dashboardConfigPath) assert.ok(response.ok(), 'The host must expose dashboard configuration') const config = validateConfig(await response.json()) @@ -29,13 +52,18 @@ test('dashboard visits record accepted beacons in the live .NET engine', async ( ) const summary = async () => { const result = await http.get(`${config.apiBaseUrl}/summary`, { - params: { range: 'today', tz: config.timeZone, site: '' }, + params: { + range: 'today', + tz: config.timeZone, + site: 'dashboard-usage', + }, }) assert.ok(result.ok()) return ((await result.json()) as SummaryReport).summary } const before = await summary() const context = await browser.newContext({ + storageState: await http.storageState(), userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', }) diff --git a/docs-site/content/docs/getting-started/aspnet-core/aspnet-core.mdx b/docs-site/content/docs/getting-started/aspnet-core/aspnet-core.mdx index e1ffc3e..2284733 100644 --- a/docs-site/content/docs/getting-started/aspnet-core/aspnet-core.mdx +++ b/docs-site/content/docs/getting-started/aspnet-core/aspnet-core.mdx @@ -23,6 +23,8 @@ Replace `Program.cs` with this development example: `AddZoneTreeAnalytics` registers a shared engine with dependency injection. Your application can inject `IAnalyticsTracker` to record custom actions or `AnalyticsEngine` to read reports. +A host that opens or replaces engines at runtime calls `AddZoneTreeAnalyticsWeb` instead. It registers only the web options and reporting limits; the tracker and reporting endpoints resolve `AnalyticsEngine` from the request services, so the host supplies it for each request, for example by replacing `HttpContext.RequestServices` with a provider that returns the engine it leased. Do not register the engine as a scoped service: the request scope would dispose it. + `UseZoneTreeAnalytics` records successful HTML GET responses. Host shutdown saves remaining work and closes the engine automatically. diff --git a/docs-site/content/docs/getting-started/dashboard/dashboard.mdx b/docs-site/content/docs/getting-started/dashboard/dashboard.mdx index b94d887..9eb7708 100644 --- a/docs-site/content/docs/getting-started/dashboard/dashboard.mdx +++ b/docs-site/content/docs/getting-started/dashboard/dashboard.mdx @@ -23,11 +23,13 @@ Then run the server from the repository root: Open **http://127.0.0.1:5088/**. The development profile starts the UI watcher automatically, so page and style changes update in the browser. -## 2. Connect your reports +## 2. Set up the server -The included server supplies API connection settings. To connect another reporting API, open **Connection** and choose its URL, sites, reporting time zone, and refresh interval. +The first visit opens the setup wizard. When startup output settles, the server's console ends with a setup link that carries a one-time **setup key**. Open the link, or type the key into the wizard; it proves you control the server, changes on every start, and stops working once setup is complete. -The default reporting prefix is `/_analytics/api`. Site IDs in the dashboard must match those used to collect events. +The wizard creates the administrator account, then asks for your sites and their domains, the reporting time zone, and a collection preset: **Privacy first**, **Standard**, or **Full detail**. Every other option has a default; open **Advanced settings** on the last step to change any of them. The time zone, data directory, write-ahead-log directory, and visitor sketch precision are stored with the data and cannot change after setup. + +The last step shows the tracking snippet. Add it to every page of your sites; the server assigns each page view to the site whose domain matches the page. ## 3. Explore your data @@ -44,13 +46,30 @@ The default reporting prefix is `/_analytics/api`. Site IDs in the dashboard mus Choose a site and date range, then add filters or comparisons to explore a specific audience. Export reports when you need to work with the results elsewhere. -## 4. Try sample datasets +## 4. Invite people and manage the server + +The administrator manages everything under **Administration** in the sidebar: + +| Page | Purpose | +| --- | --- | +| Server status | Engine state, background index work, and storage partitions | +| Sites | Websites, their domains, and the tracking snippet | +| Users | Accounts, roles, site access, and signed-in browsers | +| Indexes & rollups | Pre-aggregated reports and secondary indexes | +| Settings | Collection, privacy, retention, sessions, ingestion, queries, storage, and sign-in security | +| Audit log | Sign-ins, setting changes, and user management | + +The dashboard has one administrator. Other users are **analysts**, who read reports and run ad-hoc queries, or **viewers**, who read reports. Each sees all sites or only the sites assigned to them. + +Saving settings shows the changes and when each applies: at once, after the engine reopens for a few seconds, or after a server restart. Changes that delete data, such as a shorter retention, need an extra confirmation. Index changes are built from retained data in the background. + +## 5. Try sample datasets -In Development mode, open **Tools → Sample data** to generate reporting data or run an ingestion benchmark. +In Development mode, the administrator can open **Tools → Sample data** to generate reporting data or run an ingestion benchmark. | Dataset | Purpose | | --- | --- | -| Default site | Traffic collected by the running application | +| Your sites | Traffic collected by the running application | | Performance samples | Generated traffic for exploring report behavior | | Latest benchmark | The retained dataset from a successful ingestion benchmark | @@ -70,9 +89,9 @@ Generate the static UI: The server serves the published output from `ui/dist/publish`. Use `Dashboard:Watch=false` for static serving in Development, or `Dashboard:Path` for another output directory. -The application listens on all network interfaces by default. Apply your host's authorization policy before giving others access to reports. +The application listens on all network interfaces by default. Every report, page, and setting requires signing in; only the tracker script and beacon endpoint are public. Serve the dashboard over HTTPS so session cookies are sent only on secure connections. -Shared engine settings live in `DashboardAnalyticsOptions.cs`. Its settings apply to the primary, sample, and benchmark engines, and differ from the library defaults. See [configuration](../../operations/configuration/configuration.mdx) before changing an existing dataset. +Users, sessions, settings, and the audit log live in one ZoneTree under `data/system`; set `Dashboard:SystemDirectory` to store them elsewhere. After setup, the stored settings configure the analytics engine. `DashboardAnalyticsOptions.cs` configures only the Development sample and benchmark engines. See [configuration](../../operations/configuration/configuration.mdx) before changing an existing dataset. ## Related pages diff --git a/docs-site/content/docs/reference/configuration/configuration.mdx b/docs-site/content/docs/reference/configuration/configuration.mdx index 0dec5d8..44f9e74 100644 --- a/docs-site/content/docs/reference/configuration/configuration.mdx +++ b/docs-site/content/docs/reference/configuration/configuration.mdx @@ -11,7 +11,7 @@ Use this page to locate a setting, check its default, and understand its depende ## Configuration rules -Set group members and add to collections in place. Groups are created by `AnalyticsOptions`; they are not replaced wholesale. Options are cloned when an engine opens, so edits afterward do not reconfigure it. +Set group members and add to collections in place. Groups are created by `AnalyticsOptions`; they are not replaced wholesale. Options are cloned when an engine opens, so edits afterward do not reconfigure it. `options.Validate()` runs the engine's option checks without opening storage, for example before applying settings edited in an admin screen; dataset compatibility and ZoneTree store settings are checked when an engine opens. Reporting time zone, sketch precision, and the chosen separate write-ahead-log directory are persisted dataset choices. Reopening incompatible settings fails. Index changes can trigger background backfills; ordinary rollup and aggregate changes can introduce activation boundaries. diff --git a/docs-site/package.json b/docs-site/package.json index cc2ff1b..b1df34d 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -8,7 +8,7 @@ "build": "purestack build --content ./content --clean", "serve": "purestack serve --content ./content --port 4900 --clean", "publish": "purestack publish --content ./content", - "deploy": "pgdeploy deploy --host https://analytics.zonetree.dev --package ./dist/web --allow-path 'pagefind/*,.ts-ssg/manifest.json' --skip-path 'docs/*.json,*.cs,*.http,*.ps1,*.sh'" + "deploy": "pgdeploy deploy --host https://analytics.zonetree.dev --package ./dist/web --allow-path 'pagefind/*,.ts-ssg/manifest.json,docs/**/*.json'" }, "dependencies": { "@purestack/ts-style": "^1.1.15", diff --git a/src/ZoneTree.Analytics.AspNetCore/AnalyticsAspNetCoreExtensions.cs b/src/ZoneTree.Analytics.AspNetCore/AnalyticsAspNetCoreExtensions.cs index e156296..781ed34 100644 --- a/src/ZoneTree.Analytics.AspNetCore/AnalyticsAspNetCoreExtensions.cs +++ b/src/ZoneTree.Analytics.AspNetCore/AnalyticsAspNetCoreExtensions.cs @@ -30,11 +30,7 @@ public static IServiceCollection AddZoneTreeAnalytics( ArgumentNullException.ThrowIfNull(services); var options = new AnalyticsOptions(); configure?.Invoke(options); - var webOptions = new AnalyticsWebOptions(); - configureWeb?.Invoke(webOptions); - webOptions.Validate(); - services.AddSingleton(webOptions); - services.AddSingleton(); + services.AddZoneTreeAnalyticsWeb(configureWeb); services.AddSingleton(provider => { var environment = provider.GetService(); @@ -47,6 +43,26 @@ public static IServiceCollection AddZoneTreeAnalytics( services.AddSingleton(provider => provider.GetRequiredService()); return services; } + + /// + /// Registers the web integration options and reporting limits without an engine. The tracker + /// and reporting endpoints resolve from the request services, so + /// a host that opens, replaces or closes engines at runtime supplies one per request, for example + /// by replacing with a provider that returns the engine + /// it leased. A scoped registration would let the request scope dispose the engine. + /// + public static IServiceCollection AddZoneTreeAnalyticsWeb( + this IServiceCollection services, + Action configureWeb = null) + { + ArgumentNullException.ThrowIfNull(services); + var webOptions = new AnalyticsWebOptions(); + configureWeb?.Invoke(webOptions); + webOptions.Validate(); + services.AddSingleton(webOptions); + services.AddSingleton(); + return services; + } } /// diff --git a/src/ZoneTree.Analytics.UnitTests/DashboardAdministrationTests.cs b/src/ZoneTree.Analytics.UnitTests/DashboardAdministrationTests.cs new file mode 100644 index 0000000..6d868f3 --- /dev/null +++ b/src/ZoneTree.Analytics.UnitTests/DashboardAdministrationTests.cs @@ -0,0 +1,350 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging.Abstractions; +using ZoneTree.Analytics.AspNetCore; + +namespace ZoneTree.Analytics.UnitTests; + +// The dashboard's users, sessions, settings and engine host, on a real system store. +public sealed class DashboardAdministrationTests : DirectoryTest +{ + sealed class TestEnvironment(string root) : IWebHostEnvironment + { + public string WebRootPath { get; set; } = root; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string ApplicationName { get; set; } = "ZoneTree.Analytics.Dashboard"; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + public string ContentRootPath { get; set; } = root; + public string EnvironmentName { get; set; } = "Production"; + } + + sealed class Host : IAsyncDisposable + { + public SystemStore Store; + public UserStore Users; + public AuditLog Audit; + public EngineHost Engines; + public SettingsService Settings; + public SessionStore Sessions; + public AnalyticsWebOptions Web = new(); + + public Host(string root) + { + var environment = new TestEnvironment(root); + Store = new SystemStore(Path.Combine(root, "system")); + Users = new UserStore(Store); + Audit = new AuditLog(Store); + Engines = new EngineHost(environment, NullLoggerFactory.Instance); + Settings = new SettingsService(Store, Engines, Web, Users, Audit, environment); + Sessions = new SessionStore(Store, Users, Settings); + } + + public async ValueTask DisposeAsync() + { + await Engines.DisposeAsync(); + Store.Dispose(); + } + } + + static DashboardSettings Settings(string timeZone = "UTC") + { + var settings = SettingsService.CreateDefaults(); + settings.General.TimeZone = timeZone; + settings.Sites.Items = [new() { Id = "shop", Name = "Shop", Domains = ["shop.example.com"] }, + new() { Id = "blog", Name = "Blog", Domains = ["*.blog.example.com"] }]; + settings.Sites.PrimarySite = "shop"; + return settings; + } + + static UserChange NewUser(string name, UserRole role, params string[] sites) => + new(name, null, role, false, [.. sites], null, "a long enough password"); + + static readonly HashSet KnownSites = ["shop", "blog"]; + + [Test] + public void PasswordsVerifyOnlyTheirOwnText() + { + var hash = PasswordHasher.Hash("correct horse battery"); + Assert.Multiple(() => + { + Assert.That(hash, Does.StartWith("pbkdf2-sha256$600000$")); + Assert.That(PasswordHasher.Verify("correct horse battery", hash), Is.True); + Assert.That(PasswordHasher.Verify("correct horse batterY", hash), Is.False); + Assert.That(PasswordHasher.Verify("anything", null), Is.False, "an unknown user never signs in"); + Assert.That(PasswordHasher.Hash("correct horse battery"), Is.Not.EqualTo(hash), "every hash has its own salt"); + Assert.That(PasswordHasher.ValidateStrength("short", "user"), Is.Not.Null); + }); + } + + [Test] + public async Task TheDashboardHasExactlyOneAdministrator() + { + await using var host = new Host(directory); + var admin = host.Users.Create(NewUser("Owner", UserRole.Administrator), KnownSites); + var viewer = host.Users.Create(NewUser("viewer", UserRole.Viewer, "blog"), KnownSites); + Assert.Multiple(() => + { + Assert.Throws(() => host.Users.Create(NewUser("second", UserRole.Administrator), KnownSites)); + Assert.Throws(() => host.Users.Create(NewUser("OWNER", UserRole.Viewer), KnownSites), "names are unique ignoring case"); + Assert.Throws(() => host.Users.Update(viewer.Id, new(null, null, UserRole.Administrator, null, null, null, null), KnownSites)); + Assert.Throws(() => host.Users.Update(admin.Id, new(null, null, UserRole.Viewer, null, null, null, null), KnownSites)); + Assert.Throws(() => host.Users.Update(admin.Id, new(null, null, null, null, null, true, null), KnownSites)); + Assert.Throws(() => host.Users.Delete(admin.Id)); + Assert.Throws(() => host.Users.Create(NewUser("ghost", UserRole.Viewer, "missing"), KnownSites)); + Assert.That(host.Users.FindByName("owner").Id, Is.EqualTo(admin.Id)); + }); + } + + [Test] + public async Task RolesAndSitesDecideWhatAUserReads() + { + await using var host = new Host(directory); + var admin = host.Users.Create(NewUser("owner", UserRole.Administrator), KnownSites); + var analyst = host.Users.Create(NewUser("analyst", UserRole.Analyst, "shop"), KnownSites); + var viewer = host.Users.Create(new("viewer", null, UserRole.Viewer, true, null, null, "a long enough password"), KnownSites); + Assert.Multiple(() => + { + Assert.That(admin.CanAccess(SettingsMapper.DashboardUsageSite), Is.True); + Assert.That(analyst.CanAccess("shop"), Is.True); + Assert.That(analyst.CanAccess("blog"), Is.False); + Assert.That(analyst.CanQuery, Is.True); + Assert.That(viewer.CanAccess("blog"), Is.True, "all sites"); + Assert.That(viewer.CanAccess(SettingsMapper.DashboardUsageSite), Is.False, "reserved sites are the administrator's"); + Assert.That(viewer.CanAccess(DashboardAnalyticsOptions.PerformanceSite), Is.False); + Assert.That(viewer.CanQuery, Is.False); + }); + } + + [Test] + public async Task SessionsKeepOnlyTokenHashesAndEndWithTheirUsersSecurityChanges() + { + await using var host = new Host(directory); + var user = host.Users.Create(NewUser("analyst", UserRole.Analyst, "shop"), KnownSites); + var context = new DefaultHttpContext(); + var (token, _) = host.Sessions.Create(user, context); + var (other, _) = host.Sessions.Create(user, context); + Assert.Multiple(() => + { + Assert.That(host.Store.Contains("session/" + token), Is.False, "the token itself is never stored"); + Assert.That(host.Store.Contains("session/" + SessionStore.Id(token)), Is.True); + Assert.That(host.Sessions.TryAuthenticate(token, out var id, out var signedIn), Is.True); + Assert.That(signedIn.Id, Is.EqualTo(user.Id)); + Assert.That(host.Sessions.List(user.Id, id), Has.Count.EqualTo(2)); + Assert.That(host.Sessions.TryAuthenticate("forged", out _, out _), Is.False); + }); + Assert.That(host.Sessions.Revoke(SessionStore.Id(other), "someone-else"), Is.False, "users end only their own sessions"); + Assert.That(host.Sessions.Revoke(SessionStore.Id(other), user.Id), Is.True); + Assert.That(host.Sessions.TryAuthenticate(other, out _, out _), Is.False); + host.Users.Update(user.Id, new(null, null, UserRole.Viewer, null, null, null, null), KnownSites); + Assert.That(host.Sessions.TryAuthenticate(token, out _, out _), Is.False, "a role change ends every session"); + var (fresh, _) = host.Sessions.Create(host.Users.Get(user.Id), context); + host.Users.Update(user.Id, new(null, null, null, null, null, true, null), KnownSites); + Assert.That(host.Sessions.TryAuthenticate(fresh, out _, out _), Is.False, "a disabled user is signed out"); + Assert.That(host.Sessions.Cleanup(), Is.Zero, "ended sessions were already removed"); + } + + [Test] + public async Task UsersAndSessionsSurviveARestart() + { + string token; + await using (var host = new Host(directory)) + { + var user = host.Users.Create(NewUser("owner", UserRole.Administrator), KnownSites); + (token, _) = host.Sessions.Create(user, new DefaultHttpContext()); + } + await using var reopened = new Host(directory); + Assert.That(reopened.Users.HasAdministrator, Is.True); + Assert.That(reopened.Sessions.TryAuthenticate(token, out _, out var signedIn), Is.True); + Assert.That(signedIn.UserName, Is.EqualTo("owner")); + } + + [Test] + public void TheSchemaDescribesEverySettingAndPreset() + { + var schema = SettingsSchema.Document; + var settings = SettingsService.CreateDefaults(); + var keys = new List(); + void Visit(SettingField field) + { + if (field.Fields != null) { foreach (var child in field.Fields) Visit(child); return; } + keys.Add(field.Key); + Assert.DoesNotThrow(() => SettingsDocument.Read(settings, field.Key), field.Key); + } + foreach (var section in schema.Sections) + foreach (var field in section.Fields) + Visit(field); + Assert.Multiple(() => + { + Assert.That(schema.Sections.Select(section => section.Key), Has.Member("privacy").And.Member("indexes").And.Member("security")); + Assert.That(keys, Does.Contain("storage.rawEventStore.writeAheadLogMode")); + Assert.That(SettingsSchema.Find("general.timeZone").Tier, Is.EqualTo(SettingTier.Fixed)); + Assert.That(SettingsSchema.Find("reporting.maxConcurrentRequests").Tier, Is.EqualTo(SettingTier.Restart)); + foreach (var preset in schema.Presets) + foreach (var key in preset.Values.Keys) + Assert.That(keys, Does.Contain(key), $"{preset.Key} sets {key}"); + }); + } + + [Test] + public void SettingsMapToEngineOptions() + { + var settings = Settings("Europe/Berlin"); + settings.Retention.RawEventsDays = null; + settings.Rollups.Granularities = [RollupGranularity.Minute, RollupGranularity.Day]; + settings.Privacy.RestrictDimensions = true; + settings.Privacy.AllowedDimensions = ["plan"]; + settings.Queries.MaxDegreeOfParallelism = 0; + settings.Storage.RollupStore.MutableSegmentMaxItemCount = 12_345; + var options = SettingsMapper.ToOptions(settings, directory, null); + Assert.Multiple(() => + { + Assert.That(options.DataDirectory, Is.EqualTo(Path.GetFullPath(Path.Combine(directory, "data/analytics")))); + Assert.That(options.TimeZone.Id, Is.EqualTo("Europe/Berlin")); + Assert.That(options.DefaultSite, Is.EqualTo("shop")); + Assert.That(options.Retention.RawEvents, Is.Null); + Assert.That(options.Retention.HourRollups, Is.EqualTo(TimeSpan.FromDays(400))); + Assert.That(options.Rollups.Granularities, Is.EqualTo(Granularity.Minute | Granularity.Day)); + Assert.That(options.Rollups.ValueAggregates["load_time"], Is.EqualTo(ValueAggregates.Percentiles)); + Assert.That(options.Privacy.AllowedDimensions, Is.EquivalentTo(new[] { "plan" })); + Assert.That(options.Queries.MaxDegreeOfParallelism, Is.EqualTo(Environment.ProcessorCount)); + Assert.That(options.Storage.Rollups.MutableSegmentMaxItemCount, Is.EqualTo(12_345)); + Assert.That(options.Storage.DimensionIndexes.Dimensions, Is.EquivalentTo(DashboardIndexPreset.Dimensions)); + Assert.That(options.Storage.RollupIndexes.Definitions, Has.Count.EqualTo(12)); + Assert.That(options.Web.Bots, Is.EqualTo(BotTraffic.TrackSeparately)); + }); + Assert.DoesNotThrow(() => SettingsMapper.Validate(settings, directory, Path.Combine(directory, "system"))); + } + + [TestCase("timezone")] + [TestCase("site-id")] + [TestCase("reserved-site")] + [TestCase("duplicate-domain")] + [TestCase("range")] + [TestCase("rollup")] + [TestCase("overlap")] + public void InvalidSettingsAreRejectedWithTheirField(string problem) + { + var settings = Settings(); + var expectedKey = problem switch + { + "timezone" => "general.timeZone", + "range" => "sessions.timeoutMinutes", + "overlap" => "general.dataDirectory", + "rollup" => null, + _ => "sites.items", + }; + switch (problem) + { + case "timezone": settings.General.TimeZone = "Mars/Olympus"; break; + case "site-id": settings.Sites.Items[0].Id = "Shop Front"; break; + case "reserved-site": settings.Sites.Items[0].Id = SettingsMapper.DashboardUsageSite; break; + case "duplicate-domain": settings.Sites.Items[1].Domains = ["shop.example.com"]; break; + case "range": settings.Sessions.TimeoutMinutes = 0; break; + case "rollup": settings.Rollups.Definitions.Add(new() { Dimensions = ["page", "page"] }); break; + case "overlap": settings.General.DataDirectory = "system/analytics"; break; + } + var error = Assert.Throws(() => SettingsMapper.Validate(settings, directory, Path.Combine(directory, "system"))); + Assert.That(error.Key, Is.EqualTo(expectedKey), error.Message); + } + + [Test] + public async Task SavingSettingsReopensTheEngineAndKeepsFixedChoices() + { + await using (var host = new Host(directory)) + { + await host.Settings.CompleteSetupAsync(Settings("Europe/Berlin")); + Assert.That(host.Engines.State, Is.EqualTo(EngineState.Running)); + + var noLoss = host.Settings.Copy(); + noLoss.Indexes.Dimensions.Add("browser"); + noLoss.General.RefreshSeconds = 60; + var review = host.Settings.Review(noLoss); + Assert.That(review.Changes.Select(change => (change.Key, change.Tier)), + Is.EquivalentTo(new[] { ("general.refreshSeconds", SettingTier.Live), ("indexes.dimensions", SettingTier.Reopen) })); + var saved = await host.Settings.SaveAsync(noLoss, "owner", false, null); + Assert.That(saved.Reopened, Is.True); + Assert.That(host.Engines.State, Is.EqualTo(EngineState.Running)); + + var shorter = host.Settings.Copy(); + shorter.Retention.RawEventsDays = 7; + var warning = Assert.ThrowsAsync(() => host.Settings.SaveAsync(shorter, "owner", false, null)); + Assert.That(warning.Warnings, Has.One.Contains("older than 7 days")); + await host.Settings.SaveAsync(shorter, "owner", true, null); + + var moved = host.Settings.Copy(); + moved.General.TimeZone = "UTC"; + var fixedError = Assert.Throws(() => host.Settings.Review(moved)); + Assert.That(fixedError.Key, Is.EqualTo("general.timeZone")); + + var live = host.Settings.Copy(); + live.Tracking.AllowedClientEvents = ["signup"]; + live.Reporting.MaxConcurrentRequests = 9; + var liveResult = await host.Settings.SaveAsync(live, "owner", false, null); + Assert.Multiple(() => + { + Assert.That(liveResult.Reopened, Is.False); + Assert.That(liveResult.RestartRequired, Is.True); + Assert.That(host.Web.AllowedClientEvents, Is.EquivalentTo(new[] { "signup" })); + }); + } + await using var reopened = new Host(directory); + Assert.Multiple(() => + { + Assert.That(reopened.Settings.IsConfigured, Is.True); + Assert.That(reopened.Settings.Current.Indexes.Dimensions, Does.Contain("browser")); + Assert.That(reopened.Settings.Current.Retention.RawEventsDays, Is.EqualTo(7)); + Assert.That(reopened.Web.AllowedClientEvents, Is.EquivalentTo(new[] { "signup" }), "live settings apply at startup"); + Assert.That(reopened.Audit.List(null, 10).Select(entry => entry.Action), Is.EqualTo(Enumerable.Repeat("settings.updated", 3))); + }); + } + + [Test] + public async Task SettingsTheEngineCannotOpenLeaveThePreviousOnesRunning() + { + await using var host = new Host(directory); + await host.Settings.CompleteSetupAsync(Settings()); + var previous = host.Settings.Current; + var broken = host.Settings.Copy(); + // The dataset stores its time zone: the engine refuses another one when it opens. + broken.General.TimeZone = "Europe/Berlin"; + Assert.ThrowsAsync(() => host.Engines.ReopenAsync(broken, previous)); + Assert.That(host.Engines.State, Is.EqualTo(EngineState.Running)); + using var lease = await host.Engines.AcquireAsync(CancellationToken.None); + Assert.That(lease.Engine.TimeZone.Id, Is.EqualTo("UTC")); + } + + [TestCase("https://shop.example.com", "shop")] + [TestCase("https://news.blog.example.com", "blog")] + [TestCase("https://blog.example.com", "blog")] + [TestCase("https://unknown.test", "shop")] + [TestCase("http://dashboard.local", null)] + public void BeaconsReportToTheSiteOfTheirPage(string origin, string site) + { + var settings = Settings(); + SettingsMapper.Validate(settings, directory, null); + var context = new DefaultHttpContext(); + context.Request.Host = new HostString("dashboard.local"); + context.Request.Headers.Origin = origin; + Assert.That(new SiteResolution().Resolve(context, settings), Is.EqualTo(site)); + settings.Sites.UnmatchedTraffic = UnmatchedTraffic.Ignore; + settings.Sites.TrackDashboardUsage = true; + var expected = site == "shop" && origin.Contains("unknown") ? null : site ?? SettingsMapper.DashboardUsageSite; + Assert.That(new SiteResolution().Resolve(context, settings), Is.EqualTo(expected)); + } + + [Test] + public void SetupKeysMatchOnlyTheirOwnValueUntilRetired() + { + var key = new SetupKey(); + var value = key.Value; + Assert.Multiple(() => + { + Assert.That(value, Does.Match("^[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$")); + Assert.That(key.Matches(value.ToLowerInvariant().Replace("-", " ")), Is.True, "case and separators do not matter"); + Assert.That(key.Matches("AAAA-BBBB-CCCC"), Is.False); + }); + key.Retire(); + Assert.That(key.Matches(value), Is.False); + } +} diff --git a/src/ZoneTree.Analytics.UnitTests/DashboardAnalyticsOptionsTests.cs b/src/ZoneTree.Analytics.UnitTests/DashboardAnalyticsOptionsTests.cs index c929e1e..b6b145d 100644 --- a/src/ZoneTree.Analytics.UnitTests/DashboardAnalyticsOptionsTests.cs +++ b/src/ZoneTree.Analytics.UnitTests/DashboardAnalyticsOptionsTests.cs @@ -9,14 +9,14 @@ public sealed class DashboardAnalyticsOptionsTests : DirectoryTest public void ProfilesShareRetentionAndIndexCoverageWithoutSharingMutableOptions() { var zone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); - var primary = new AnalyticsOptions(); - DashboardAnalyticsOptions.ConfigurePrimary(primary, "primary", zone); + var primary = Primary("primary", zone.Id); var samples = DashboardAnalyticsOptions.CreatePerformanceSamples("samples", zone, 4, NullLogger.Instance); var benchmark = DashboardAnalyticsOptions.CreateBenchmark("benchmark", zone, 20, false, true, NullLogger.Instance); var reports = DashboardAnalyticsOptions.CreateBenchmarkReport("retained", zone.Id, true, true, NullLogger.Instance); foreach (var options in new[] { primary, samples, benchmark, reports }) { - Assert.That(options.Retention.RawEvents, Is.EqualTo(TimeSpan.FromDays(365 * 3))); + // The primary engine keeps what its settings say: a year of raw events by default. + Assert.That(options.Retention.RawEvents, Is.EqualTo(TimeSpan.FromDays(options == primary ? 365 : 365 * 3))); Assert.That(options.TimeZone.Id, Is.EqualTo(zone.Id)); Assert.That(options.Web.Bots, Is.EqualTo(BotTraffic.TrackSeparately)); Assert.That(options.Web.GeoLocator, Is.Null, "real traffic must never get invented countries"); @@ -28,7 +28,7 @@ public void ProfilesShareRetentionAndIndexCoverageWithoutSharingMutableOptions() } Assert.Multiple(() => { - Assert.That(primary.DataDirectory, Is.EqualTo("primary")); + Assert.That(primary.DataDirectory, Is.EqualTo(Path.GetFullPath(Path.Combine(directory, "primary")))); Assert.That(samples.DataDirectory, Is.EqualTo("samples")); Assert.That(benchmark.DataDirectory, Is.EqualTo("benchmark")); Assert.That(reports.DataDirectory, Is.EqualTo("retained")); @@ -56,7 +56,7 @@ public void ProfilesShareRetentionAndIndexCoverageWithoutSharingMutableOptions() samples.Rollups.ValueAggregates.Clear(); foreach (var options in new[] { primary, benchmark, reports }) { - Assert.That(options.Retention.RawEvents, Is.EqualTo(TimeSpan.FromDays(365 * 3))); + Assert.That(options.Retention.RawEvents, Is.EqualTo(TimeSpan.FromDays(options == primary ? 365 : 365 * 3))); Assert.That(options.Storage.RollupIndexes.Definitions, Has.Count.EqualTo(12)); Assert.That(options.Storage.DimensionIndexes.Dimensions, Has.Count.EqualTo(4)); } @@ -73,9 +73,8 @@ public void EveryEngineRetainsGeneratedHistoryOlderThanNinetyDays(string profile { "samples" => DashboardAnalyticsOptions.CreatePerformanceSamples(directory, TimeZoneInfo.Utc, 1, NullLogger.Instance), "benchmark" => DashboardAnalyticsOptions.CreateBenchmark(directory, TimeZoneInfo.Utc, 1, false, true, NullLogger.Instance), - _ => new AnalyticsOptions() + _ => Primary(directory, "UTC") }; - if (profile == "primary") DashboardAnalyticsOptions.ConfigurePrimary(options, directory, TimeZoneInfo.Utc); options.Retention.CheckInterval = null; options.Ingestion.FlushInterval = TimeSpan.FromHours(1); options.Storage.Logger ??= new RecordingLogger(); @@ -94,4 +93,13 @@ public void EveryEngineRetainsGeneratedHistoryOlderThanNinetyDays(string profile using var reopened = new AnalyticsEngine(options); Assert.That(reopened.Web.GetSummary(new() { Range = Day(at.Date) }).Summary.PageViews, Is.EqualTo(1)); } + + // The primary engine as the setup wizard's default settings configure it. + AnalyticsOptions Primary(string dataDirectory, string timeZone) + { + var settings = SettingsService.CreateDefaults(); + settings.General.TimeZone = timeZone; + settings.General.DataDirectory = dataDirectory; + return SettingsMapper.ToOptions(settings, directory, null); + } } diff --git a/src/ZoneTree.Analytics.UnitTests/ZoneTree.Analytics.UnitTests.csproj b/src/ZoneTree.Analytics.UnitTests/ZoneTree.Analytics.UnitTests.csproj index bf80025..1b2d13b 100644 --- a/src/ZoneTree.Analytics.UnitTests/ZoneTree.Analytics.UnitTests.csproj +++ b/src/ZoneTree.Analytics.UnitTests/ZoneTree.Analytics.UnitTests.csproj @@ -27,6 +27,7 @@ + @@ -34,13 +35,5 @@ - - - - - - - - diff --git a/src/ZoneTree.Analytics/AnalyticsOptions.cs b/src/ZoneTree.Analytics/AnalyticsOptions.cs index b98714f..f0bf01e 100644 --- a/src/ZoneTree.Analytics/AnalyticsOptions.cs +++ b/src/ZoneTree.Analytics/AnalyticsOptions.cs @@ -117,7 +117,14 @@ internal AnalyticsOptions Clone() return clone; } - internal void Validate() + /// + /// Checks the options the way the engine does before it opens the data directory, without + /// touching storage. Compatibility with an existing data directory (time zone, sketch + /// precision, write-ahead-log directory) and ZoneTree store settings are checked only when an + /// engine opens. + /// + /// The options are invalid. + public void Validate() { if (string.IsNullOrWhiteSpace(DataDirectory)) throw new ArgumentException("DataDirectory is required."); From 9a7280814d939973da117db2cfe1f6c58d81b586 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Mon, 19 Oct 2026 05:13:11 +0200 Subject: [PATCH 2/3] Upgrade purestack and regor. --- .../ui/design/styles/admin.ts | 7 +- .../ui/package.json | 16 +- docs-site/package.json | 8 +- yarn.lock | 292 ++++++++++-------- 4 files changed, 177 insertions(+), 146 deletions(-) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts index 8e42fd4..9328ec2 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/design/styles/admin.ts @@ -473,9 +473,10 @@ function registerAdminResponsiveStyles({ root }: DashboardStyleContext) { .css({ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }) tablet.select('.admin-columns').css({ gridTemplateColumns: 'minmax(0, 1fr)' }) const narrow = root.media('max-width: 850px') - narrow - .select('.auth-shell') - .css({ gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'auto 1fr' }) + narrow.select('.auth-shell').css({ + gridTemplateColumns: 'minmax(0, 1fr)', + gridTemplateRows: 'auto 1fr', + }) narrow .select('.auth-aside') .css({ position: 'static', height: 'auto', padding: '24px', gap: '16px' }) diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/package.json b/apps/ZoneTree.Analytics.Dashboard/ui/package.json index c60597a..e8e8036 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/package.json +++ b/apps/ZoneTree.Analytics.Dashboard/ui/package.json @@ -10,14 +10,14 @@ "publish": "purestack publish --content ./content" }, "dependencies": { - "@purestack/ts-components": "^1.1.15", - "@purestack/ts-css": "^1.1.15", - "@purestack/ts-html": "^1.1.15", - "@purestack/ts-style": "^1.1.15", - "@purestack/ts-svg-icons": "^1.1.15", - "@purestack/ts-util": "^1.1.15", - "purestack": "^1.1.15", - "regor": "^1.7.5" + "@purestack/ts-components": "^1.1.16", + "@purestack/ts-css": "^1.1.16", + "@purestack/ts-html": "^1.1.16", + "@purestack/ts-style": "^1.1.16", + "@purestack/ts-svg-icons": "^1.1.16", + "@purestack/ts-util": "^1.1.16", + "purestack": "^1.1.16", + "regor": "^1.7.6" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/docs-site/package.json b/docs-site/package.json index b1df34d..c0cc0b7 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -11,10 +11,10 @@ "deploy": "pgdeploy deploy --host https://analytics.zonetree.dev --package ./dist/web --allow-path 'pagefind/*,.ts-ssg/manifest.json,docs/**/*.json'" }, "dependencies": { - "@purestack/ts-style": "^1.1.15", - "@purestack/ts-util": "^1.1.15", - "purestack": "^1.1.15", - "regor": "^1.7.5" + "@purestack/ts-style": "^1.1.16", + "@purestack/ts-util": "^1.1.16", + "purestack": "^1.1.16", + "regor": "^1.7.6" }, "devDependencies": { "typescript": "^7.0.2" diff --git a/yarn.lock b/yarn.lock index 0741c95..89aa2c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -413,139 +413,141 @@ __metadata: languageName: node linkType: hard -"@purestack/ts-common@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-common@npm:1.1.15" +"@purestack/ts-common@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-common@npm:1.1.16" dependencies: - "@purestack/ts-html": "npm:^1.1.15" - "@purestack/ts-style": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" - checksum: 10c0/aa6239d2ee76293be277053a7c558d425505ef564f5e879691eac22a567731d83d0a41599f090fbb9164bdcbef0e4e381ed26f175854809e21022a9da491604a + "@purestack/ts-html": "npm:^1.1.16" + "@purestack/ts-style": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" + checksum: 10c0/56df4fb08d5c76dcd185c3a7654e8928b0d884515228b0c6688d7bf91d0422d8464014533329f9f2b1b5d931b85280771edf0e501c85eca255037dc76f0fbfaa languageName: node linkType: hard -"@purestack/ts-components@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-components@npm:1.1.15" +"@purestack/ts-components@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-components@npm:1.1.16" dependencies: - "@purestack/ts-common": "npm:^1.1.15" - "@purestack/ts-css": "npm:^1.1.15" - "@purestack/ts-page-scripts": "npm:^1.1.15" - "@purestack/ts-style": "npm:^1.1.15" - "@purestack/ts-svg-icons": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" - regor: "npm:^1.7.5" - checksum: 10c0/b1c0f91654b843dbf3f778514b4f4aee78f61f6cb2cafd4f80d80a234ad4b96b41dbdcf714c12361b9ec3b2b11173c4b4a55be06adb6b05bcae06a9de2e0832a + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-css": "npm:^1.1.16" + "@purestack/ts-page-scripts": "npm:^1.1.16" + "@purestack/ts-style": "npm:^1.1.16" + "@purestack/ts-svg-icons": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" + regor: "npm:^1.7.6" + checksum: 10c0/5f937db0264a36ea2a6ef9ff80c6670ffe94b5d20dabe728c0f5bb42ad5747d3365d5ca4480da379aecd6940e8735dbbdf94b3333ea389a6144e3eaf6fdbd1ba languageName: node linkType: hard -"@purestack/ts-css@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-css@npm:1.1.15" +"@purestack/ts-css@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-css@npm:1.1.16" dependencies: - "@purestack/ts-util": "npm:^1.1.15" + "@purestack/ts-util": "npm:^1.1.16" prettier: "npm:^3.8.1" - checksum: 10c0/7c09845eacb0fbd93660a16a3a8fdb817b61139e6c3a398300c557d69c7f1b62ee7865160ff84d4a13b67f61e1ff5b5f79af8cd4756e512ab96498d1600244b9 + checksum: 10c0/8df1b8c55ebd8a64110dadfc75e37d369c91f81bef8c9f8feddafac293bce412823881e5dd8d0ee6b84c8c2d7fcf6948d8f2d9eeaecfc9d55b4c807c2c83bb9b languageName: node linkType: hard -"@purestack/ts-highlight@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-highlight@npm:1.1.15" +"@purestack/ts-highlight@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-highlight@npm:1.1.16" dependencies: - "@purestack/ts-common": "npm:^1.1.15" + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-util-node": "npm:^1.1.16" highlight.js: "npm:^11.11.1" shiki: "npm:^3.22.0" - checksum: 10c0/4388a865fe7c2e5b07f4da91e05be8d3b2bede591fffba4b1d522383dc26424302d5d99a2910770bf2ea0e0aa6767284653886647d88f0982a0e4840ab95fc94 + checksum: 10c0/60fa6b5302b32c193eb5f19d6195cc4ffee0a9dd0a6dad86a81a50cef4a821000a70ca02074941ca9f4e19f258cd1983b064269240104b5c7942534eb0fd5346 languageName: node linkType: hard -"@purestack/ts-html@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-html@npm:1.1.15" +"@purestack/ts-html@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-html@npm:1.1.16" dependencies: - "@purestack/ts-util": "npm:^1.1.15" + "@purestack/ts-util": "npm:^1.1.16" prettier: "npm:^3.8.1" - checksum: 10c0/509c22aa553c100ab9161516ad8cf048a64174e6d9ceb47d893850dde658f783b92da9647e1180fd5e1b2a871a83f0ca2712783a2ffa424f2803e1d65552063b + checksum: 10c0/d1af391efa3af14011e61ff2f11ce3effc906b7a09e07c3c94079016c7c40705d9237d80b0d481b879d6b2610d4c14986247eb66d59ea77f5a6802932a1b70de languageName: node linkType: hard -"@purestack/ts-minidom@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-minidom@npm:1.1.15" - checksum: 10c0/3ed2780232215dcb08dff5354f1cb26e6fb15aa2b536a055e8a7a0924708fc2983e6c4aee7a566970346248bbc40ce58dc8be148565415ab5039ca8453f89957 +"@purestack/ts-minidom@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-minidom@npm:1.1.16" + checksum: 10c0/d4ce67841d913ba46e57ddda3ca42fe2289494a73264d77666ac113e822a310cdf02d462582e410bb51697a7ba47a8e2fae25d6c2e4ab89abca52f5462178fa3 languageName: node linkType: hard -"@purestack/ts-page-scripts@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-page-scripts@npm:1.1.15" +"@purestack/ts-page-scripts@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-page-scripts@npm:1.1.16" dependencies: - "@purestack/ts-common": "npm:^1.1.15" - "@purestack/ts-html": "npm:^1.1.15" - checksum: 10c0/d086b81ac1b73ca4cd2df2077822552b914f46d36ed506767be9305cebebbdf51f92fdf7725d60449a66014d4ead1a2353adcadf91c423855b780d80c1299021 + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-html": "npm:^1.1.16" + checksum: 10c0/758469045640fad1b26c525b07fe95a2d6791e28dc83126ac74d3ba7e792255ae02ab0ac225360b462ad9ed335572336b9790d6def3360ec88100ea905e03690 languageName: node linkType: hard -"@purestack/ts-pagefind@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-pagefind@npm:1.1.15" +"@purestack/ts-pagefind@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-pagefind@npm:1.1.16" dependencies: - "@purestack/ts-common": "npm:^1.1.15" + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" logpot: "npm:^1.0.7" pagefind: "npm:^1.4.0" - checksum: 10c0/189704c83248d8ef41422593970682d3084fb6709aa63bce6cb01f4221ef1b0115ea642f560bbaf2f37282daac6bb651f70a19f4a28fec7f4a5483fc0b2ed850 + checksum: 10c0/ee3010efd40c40ee5706cf68c6ebc0b561384b48bd8d96c9ae920f98a36320d60dce33e1d341a18751ee42af4aa8a8e00b4eb46da038e1f9ca59f56c04aaeef1 languageName: node linkType: hard -"@purestack/ts-render@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-render@npm:1.1.15" +"@purestack/ts-render@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-render@npm:1.1.16" dependencies: - "@purestack/ts-common": "npm:^1.1.15" - "@purestack/ts-minidom": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" - regor: "npm:^1.7.5" - checksum: 10c0/c24f6722ff77d7dd29c51e865b1b1a1e6fea36b5302df7a1f3f17481d8cd98f3a5dc20188bf2c41fbe3e2c71136a267855dc9100457ca529e6e1a1a161d87316 + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-minidom": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" + regor: "npm:^1.7.6" + checksum: 10c0/a6cfd54e51e0873a7586c82a975f084a1489fd1627fe12b073ff50a5c1d12a15243338ace16abb5382a7b24cae5c2ac4d1f633dd350b8fa63c0c2110eee22241 languageName: node linkType: hard -"@purestack/ts-seo@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-seo@npm:1.1.15" +"@purestack/ts-seo@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-seo@npm:1.1.16" dependencies: - "@purestack/ts-common": "npm:^1.1.15" - "@purestack/ts-html": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" - checksum: 10c0/707c84b87000b89a465baeded777f81d80cd868409f0efce253b77b8806f371f3091c89e5185d6146940b141ce94eae0da9cf22db6cdb95e0e57e80aae293680 + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-html": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" + checksum: 10c0/7b98eb4943db31fc01796e5e7194d48f2214f0bfd838119cedb1d3e279e128f5b66f3b624b39cc2294c9725d7b7ab899e43cff8521f4018189fc61bd48b93722 languageName: node linkType: hard -"@purestack/ts-ssg@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-ssg@npm:1.1.15" +"@purestack/ts-ssg@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-ssg@npm:1.1.16" dependencies: "@11ty/gray-matter": "npm:^3.0.0" - "@purestack/ts-common": "npm:^1.1.15" - "@purestack/ts-components": "npm:^1.1.15" - "@purestack/ts-highlight": "npm:^1.1.15" - "@purestack/ts-html": "npm:^1.1.15" - "@purestack/ts-minidom": "npm:^1.1.15" - "@purestack/ts-page-scripts": "npm:^1.1.15" - "@purestack/ts-pagefind": "npm:^1.1.15" - "@purestack/ts-render": "npm:^1.1.15" - "@purestack/ts-seo": "npm:^1.1.15" - "@purestack/ts-style": "npm:^1.1.15" - "@purestack/ts-theme": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" - "@purestack/ts-util-node": "npm:^1.1.15" + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-components": "npm:^1.1.16" + "@purestack/ts-highlight": "npm:^1.1.16" + "@purestack/ts-html": "npm:^1.1.16" + "@purestack/ts-minidom": "npm:^1.1.16" + "@purestack/ts-page-scripts": "npm:^1.1.16" + "@purestack/ts-pagefind": "npm:^1.1.16" + "@purestack/ts-render": "npm:^1.1.16" + "@purestack/ts-seo": "npm:^1.1.16" + "@purestack/ts-style": "npm:^1.1.16" + "@purestack/ts-theme": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" + "@purestack/ts-util-node": "npm:^1.1.16" esbuild: "npm:^0.28.0" hast-util-from-html: "npm:^2.0.3" hast-util-to-html: "npm:^9.0.5" html-minifier-next: "npm:^4.19.1" logpot: "npm:^1.0.7" mdast-util-to-hast: "npm:^13.2.1" - regor: "npm:^1.7.5" + regor: "npm:^1.7.6" rehype-raw: "npm:^7.0.0" rehype-stringify: "npm:^10.0.1" remark-gfm: "npm:^4.0.1" @@ -554,55 +556,58 @@ __metadata: unified: "npm:^11.0.5" bin: ts-ssg: dist/cli.mjs - checksum: 10c0/4602b9c2ca203a19330c742de64167867e0ba830b67ecade14aa44659f842f190e8f4e9b2164ed6e6b4497b7ef02f4e146979f4d11d526fc1bab2a4653bc52c4 + checksum: 10c0/efaa965b20934c8509734b3dd1775a012d690c8bb86a96ee8382b9554fc2aed42786457b82b48dab0041c8a3befade636edfa124982f51b100f087514630e3c8 languageName: node linkType: hard -"@purestack/ts-style@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-style@npm:1.1.15" +"@purestack/ts-style@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-style@npm:1.1.16" dependencies: - "@purestack/ts-css": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" - checksum: 10c0/249ab37f73e4ca398d7471fae03ab3ae5c5fe4e3cba672699650df75f062ea0bf50e4477eb4218611bde3337e407646fa63faadfd40769dd564ef25a7f52a6c0 + "@purestack/ts-css": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" + checksum: 10c0/3bca4dd5461a69bad1d2647814994b4d0c6a5db05e82929f470f9347ecb65fb1ff6e3645ce2e9125510826219c1fe2447e7b5a11ef0ef2881aa981d9b9dd4eb0 languageName: node linkType: hard -"@purestack/ts-svg-icons@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-svg-icons@npm:1.1.15" - checksum: 10c0/f746f320b48b2e3d7698c2dcd3ec2f3a06b0fa6c4083484e692559300a8a41072c310ce2a90e511218d6682f9c284bec46520765d221503a6273be75e7f8b6b9 +"@purestack/ts-svg-icons@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-svg-icons@npm:1.1.16" + checksum: 10c0/84aa42bcbb47da81b86e5476cd77e8de936401f9c31c36523799d7762db4e0a5e80bbb09c0e846349e2bf65c00fc89927829075280fa82caec79558a736a770e languageName: node linkType: hard -"@purestack/ts-theme@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-theme@npm:1.1.15" +"@purestack/ts-theme@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-theme@npm:1.1.16" dependencies: - "@purestack/ts-common": "npm:^1.1.15" - "@purestack/ts-html": "npm:^1.1.15" - "@purestack/ts-page-scripts": "npm:^1.1.15" - "@purestack/ts-style": "npm:^1.1.15" - "@purestack/ts-svg-icons": "npm:^1.1.15" - "@purestack/ts-util-node": "npm:^1.1.15" + "@purestack/ts-common": "npm:^1.1.16" + "@purestack/ts-html": "npm:^1.1.16" + "@purestack/ts-page-scripts": "npm:^1.1.16" + "@purestack/ts-style": "npm:^1.1.16" + "@purestack/ts-svg-icons": "npm:^1.1.16" + "@purestack/ts-util-node": "npm:^1.1.16" "@types/hast": "npm:^3.0.4" highlight.js: "npm:^11.11.1" logpot: "npm:^1.0.7" - checksum: 10c0/b866afd907b76a6d87e6654a36f4ea6a4efa4c6ac2304114d24ea87551da4a918667bd7952c5b3ca3762ff083a08c53e7ab3355e3fd8d759685874d99b20bc03 + checksum: 10c0/1501f24f8e265a2e4d68fe80f88a5d24ed945955745a2c6351ef55ce0c0de7fdcc20848fc1b1bfcda910b2cc603d7d83c1430a81c68779c1b90579a6c46ef412 languageName: node linkType: hard -"@purestack/ts-util-node@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-util-node@npm:1.1.15" - checksum: 10c0/d9eb7894513c629036bf64f47360e3dc9d32b329b8a76c78c9c1ecae264892de136ff4226edb348a8c7d510c8970b533f26bdc17965be2cddd29f85af43feaed +"@purestack/ts-util-node@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-util-node@npm:1.1.16" + dependencies: + "@purestack/ts-util": "npm:^1.1.16" + proper-lockfile: "npm:^4.1.2" + checksum: 10c0/b6002edeb96ebccdab18969171c36d7d3e41a801baacb3f60da2ba70824ad1d2181d66a9013b0ad322adf58da38d54760592a5cb48a0b68d7d76c14907e62006 languageName: node linkType: hard -"@purestack/ts-util@npm:^1.1.15": - version: 1.1.15 - resolution: "@purestack/ts-util@npm:1.1.15" - checksum: 10c0/1c47d80a31a7b48b8e0922008b1f2bfe6db315693d3f7917197e21d028a3e0848e071038d242dff4065c9a5238dfcd98461cd836e7bae7438b8d3704c1a03645 +"@purestack/ts-util@npm:^1.1.16": + version: 1.1.16 + resolution: "@purestack/ts-util@npm:1.1.16" + checksum: 10c0/4be2f83f49698fa2db10ab3555449fd7bafcf590dfe964d6757c38fc09cb99fe3c4469e9a3c5ec0bdfd02769e52d978f1cf7aec52568b4f746008c52fd1e0e31 languageName: node linkType: hard @@ -1061,10 +1066,10 @@ __metadata: version: 0.0.0-use.local resolution: "analytics.zonetree.dev@workspace:docs-site" dependencies: - "@purestack/ts-style": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" - purestack: "npm:^1.1.15" - regor: "npm:^1.7.5" + "@purestack/ts-style": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" + purestack: "npm:^1.1.16" + regor: "npm:^1.7.6" typescript: "npm:^7.0.2" languageName: unknown linkType: soft @@ -1460,7 +1465,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -2564,6 +2569,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/2f265dbad15897a43110a02dae55105c04d356ec4ed560723dcb9f0d34bc4fb2f13f79bb930e7561be10278e2314db5aca2527d5d3dcbbdee5e6b331d1571f6d + languageName: node + linkType: hard + "property-information@npm:^7.0.0": version: 7.2.0 resolution: "property-information@npm:7.2.0" @@ -2571,14 +2587,14 @@ __metadata: languageName: node linkType: hard -"purestack@npm:^1.1.15": - version: 1.1.15 - resolution: "purestack@npm:1.1.15" +"purestack@npm:^1.1.16": + version: 1.1.16 + resolution: "purestack@npm:1.1.16" dependencies: - "@purestack/ts-ssg": "npm:^1.1.15" + "@purestack/ts-ssg": "npm:^1.1.16" bin: purestack: dist/cli.mjs - checksum: 10c0/04fcaf59ff20bf50065cdc0b0a216a41ca6a0ce2ca51ee9275eb24012fd514957453dcadc403cfd2dca84be7f4bf818f77e3cd2448a97b4f51d86592b923b86a + checksum: 10c0/7e6b89272113b8f5212d432e26f72fefb3f66942e5f151672b246c77346611437c1d45281a4a00e361123b8cfc36da1f7bd948c4216085b1e7803a94604442b4 languageName: node linkType: hard @@ -2607,10 +2623,10 @@ __metadata: languageName: node linkType: hard -"regor@npm:^1.7.5": - version: 1.7.5 - resolution: "regor@npm:1.7.5" - checksum: 10c0/a6a3ebde793e1b9d4d5c168540618b1bba427e31334f1dac54dc0f93fe3940fd2db9dd47151b986bb8aed959bf95ce01ee74c0a30aca292a02aa4402e9cb998b +"regor@npm:^1.7.6": + version: 1.7.6 + resolution: "regor@npm:1.7.6" + checksum: 10c0/3427b77e524ef2ee7957c65e25958c2aa0d88709d419a0e21ddd82181798af89cfa25a73ccb6c2e1336f80d44867716073c027a0e2e9ce865dda9003a732a6cb languageName: node linkType: hard @@ -2693,6 +2709,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe + languageName: node + linkType: hard + "rolldown@npm:~1.2.12": version: 1.2.14 resolution: "rolldown@npm:1.2.14" @@ -2797,6 +2820,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:^3.0.2": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + "snake-case@npm:^3.0.4": version: 3.0.4 resolution: "snake-case@npm:3.0.4" @@ -3365,16 +3395,16 @@ __metadata: version: 0.0.0-use.local resolution: "zonetree-analytics-dashboard@workspace:apps/ZoneTree.Analytics.Dashboard/ui" dependencies: - "@purestack/ts-components": "npm:^1.1.15" - "@purestack/ts-css": "npm:^1.1.15" - "@purestack/ts-html": "npm:^1.1.15" - "@purestack/ts-style": "npm:^1.1.15" - "@purestack/ts-svg-icons": "npm:^1.1.15" - "@purestack/ts-util": "npm:^1.1.15" + "@purestack/ts-components": "npm:^1.1.16" + "@purestack/ts-css": "npm:^1.1.16" + "@purestack/ts-html": "npm:^1.1.16" + "@purestack/ts-style": "npm:^1.1.16" + "@purestack/ts-svg-icons": "npm:^1.1.16" + "@purestack/ts-util": "npm:^1.1.16" "@types/node": "npm:^22.0.0" playwright: "npm:^1.56.1" - purestack: "npm:^1.1.15" - regor: "npm:^1.7.5" + purestack: "npm:^1.1.16" + regor: "npm:^1.7.6" tsx: "npm:^4.20.6" typescript: "npm:^7.0.2" languageName: unknown From 9133ba7299ab352161e1e2c463efad423d4e5cb8 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Mon, 19 Oct 2026 05:43:12 +0200 Subject: [PATCH 3/3] Security and deployment Analyst-only queries: a viewer now gets 403 on /query, /query/ and /QUERY/; analysts still get through. I attached the analyst requirement to the query endpoint itself instead of matching the URL. The engine middleware now picks the engine from the same kind of endpoint tag, so no check depends on how a URL is spelled. Sign-in redirects: return URLs with a tab, a newline or a backslash all end on the dashboard's front page after sign-in, and a normal path like /pages/?site=blog is kept. The check now asks the browser's own URL parser and accepts only the same origin. A new web test covers these cases. Reverse proxies: the server now reads the forwarded headers proxies send, trusting loopback proxies by default. Other proxy addresses go in a new Dashboard:KnownProxies setting, which the dashboard README and docs-site guide now describe. A sign-in through an HTTPS proxy succeeds and gets a Secure cookie, and other origins still get 403. Sign-in throttle: of 20 simultaneous wrong passwords, 5 get 401 and 15 get 429. Attempts are counted before the password check. A second, larger limit per client address stops user-name rotation. At most half the processors hash passwords at once; extra attempts wait their turn. --- apps/ZoneTree.Analytics.Dashboard/README.md | 5 +- .../server/Admin/AdminEndpoints.cs | 4 + .../server/Auth/AuthEndpoints.cs | 14 ++-- .../server/Auth/DashboardAuthentication.cs | 1 + .../server/Auth/DashboardGates.cs | 18 ++++- .../server/Auth/SignInThrottle.cs | 76 +++++++++++++------ .../server/Engine/EngineMiddleware.cs | 68 +++++++++++------ .../server/Engine/SiteResolution.cs | 21 +++-- .../server/Program.cs | 33 ++++++-- .../server/SampleBenchmark.cs | 10 ++- .../server/SampleDataStore.cs | 4 +- .../server/Settings/SettingsMapper.cs | 39 ++++++++++ .../server/Settings/SettingsService.cs | 3 + .../server/Setup/SetupEndpoints.cs | 5 +- .../ui/content/admin/users/users.ts | 6 +- .../ui/content/lib/session.ts | 20 ++++- .../ui/tests/session.test.ts | 27 +++++++ .../getting-started/dashboard/dashboard.mdx | 2 +- .../DashboardAdministrationTests.cs | 70 +++++++++++++++++ 19 files changed, 337 insertions(+), 89 deletions(-) create mode 100644 apps/ZoneTree.Analytics.Dashboard/ui/tests/session.test.ts diff --git a/apps/ZoneTree.Analytics.Dashboard/README.md b/apps/ZoneTree.Analytics.Dashboard/README.md index ba2ea9f..e2f8fc4 100644 --- a/apps/ZoneTree.Analytics.Dashboard/README.md +++ b/apps/ZoneTree.Analytics.Dashboard/README.md @@ -53,7 +53,10 @@ one ZoneTree under `server/data/system`; choose another directory with `--Dashboard:SystemDirectory=`. After setup, these stored settings are the source of truth for the analytics engine. Passwords are PBKDF2-SHA256 hashes. Session cookies are HttpOnly and SameSite=Lax, marked Secure over HTTPS, and state-changing requests from other -origins are rejected. Serve the dashboard over HTTPS in production. +origins are rejected. Serve the dashboard over HTTPS in production. Behind a reverse proxy that +terminates HTTPS, the server reads `X-Forwarded-Proto`, `X-Forwarded-Host` and `X-Forwarded-For` from +loopback proxies; list other proxy addresses with `--Dashboard:KnownProxies=10.0.0.5,10.0.0.6`. +Failed sign-ins pause a user name from one address, and with a larger limit the address itself. The host listens on all network interfaces by default. Startup logs include a clickable local dashboard URL; other devices can connect using `http://:5088/`. diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs b/apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs index 5ce3b70..921285a 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs @@ -42,6 +42,10 @@ public static void MapDashboardAdmin(this IEndpointRouteBuilder app) admin.MapPut("/users/{id}", async (string id, HttpContext context, UserStore users, SettingsService settings, AuditLog audit) => { var change = await DashboardJson.ReadAsync(context.Request); + // Your own password changes on the account page, which checks the current one and keeps + // this browser signed in. + if (change.Password != null && id == DashboardAuthentication.User(context).Id) + throw new UserStoreException("Change your own password in Account settings."); var user = users.Update(id, change, AuthEndpoints.KnownSites(settings)); audit.Write(Actor(context), "user.updated", Describe(user.UserName, change), context); return DashboardJson.Ok(UserView.From(user)); diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs index fe09309..7c882b0 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs @@ -39,13 +39,13 @@ static async Task SignInAsync(HttpContext context, UserStore users, Ses var request = await DashboardJson.ReadAsync(context.Request); var userName = request.UserName?.Trim() ?? ""; if (userName.Length > 64) userName = userName[..64]; - if (throttle.Paused(SignInScope, userName, context) is { } wait) + if (throttle.Begin(SignInScope, userName, context) is { } wait) return TooManyAttempts(context, wait); var user = users.FindByName(userName); - var valid = PasswordHasher.Verify(request.Password, user?.PasswordHash) && user is { Disabled: false }; + var valid = await throttle.HashAsync(() => PasswordHasher.Verify(request.Password, user?.PasswordHash), context.RequestAborted) + && user is { Disabled: false }; if (!valid) { - throttle.Failed(SignInScope, userName, context); audit.Write(userName, "auth.signin-failed", user == null ? "Unknown user name." : user.Disabled ? "The account is disabled." : "Wrong password.", context); return DashboardJson.Error("The user name or password is incorrect.", StatusCodes.Status401Unauthorized); } @@ -91,13 +91,11 @@ static async Task ChangePasswordAsync(HttpContext context, UserStore us { var request = await DashboardJson.ReadAsync(context.Request); var user = DashboardAuthentication.User(context); - if (throttle.Paused("password", user.Id, context) is { } wait) + if (throttle.Begin("password", user.Id, context) is { } wait) return TooManyAttempts(context, wait); - if (!PasswordHasher.Verify(request.CurrentPassword, user.PasswordHash)) - { - throttle.Failed("password", user.Id, context); + if (!await throttle.HashAsync(() => PasswordHasher.Verify(request.CurrentPassword, user.PasswordHash), context.RequestAborted)) return DashboardJson.Error("The current password is incorrect."); - } + throttle.Succeeded("password", user.Id, context); var updated = users.Update(user.Id, new UserChange(null, null, null, null, null, null, request.NewPassword ?? ""), KnownSites(settings)); sessions.RevokeAll(user.Id); var (token, session) = sessions.Create(updated, context); diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs index 04fa131..4a5a66e 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardAuthentication.cs @@ -44,6 +44,7 @@ sealed class DashboardAuthenticationHandler(IOptionsMonitor HandleAuthenticateAsync() { + if (DashboardPaths.IsStaticAsset(Request.Path)) return Task.FromResult(AuthenticateResult.NoResult()); var token = Request.Cookies[SessionStore.CookieName]; if (string.IsNullOrEmpty(token)) return Task.FromResult(AuthenticateResult.NoResult()); if (!sessions.TryAuthenticate(token, out var id, out var user)) diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs index 18fc5d3..59bf90f 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/DashboardGates.cs @@ -1,3 +1,18 @@ +static class DashboardPaths +{ + // Scripts, styles, fonts and images: files other than pages, outside the dashboard's APIs. + // They are public and need no session. + public static bool IsStaticAsset(PathString path) + { + var value = path.Value ?? ""; + if (value.StartsWith("/_dashboard/", StringComparison.OrdinalIgnoreCase) + || value.StartsWith(EngineMiddleware.ApiPath + "/", StringComparison.OrdinalIgnoreCase)) + return false; + var extension = Path.GetExtension(value); + return extension.Length > 0 && !extension.Equals(".html", StringComparison.OrdinalIgnoreCase); + } +} + // Whether the setup wizard has created the administrator. Cached: checked on every page request. sealed class SetupStatus(UserStore users, SettingsService settings) { @@ -42,8 +57,7 @@ public Task InvokeAsync(HttpContext context) var path = request.Path.Value ?? "/"; if (!(HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method)) || path.StartsWith("/_", StringComparison.Ordinal)) return next(context); - var extension = Path.GetExtension(path); - if (extension.Length > 0 && !extension.Equals(".html", StringComparison.OrdinalIgnoreCase)) + if (DashboardPaths.IsStaticAsset(request.Path)) return next(context); var page = PageOf(path); if (!setup.Complete) diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs b/apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs index 8351ca4..aad10a8 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Auth/SignInThrottle.cs @@ -1,9 +1,11 @@ using System.Collections.Concurrent; -// Pauses sign-in for a user name and client address after repeated failures. Kept in memory: -// a restart clears it. +// Pauses sign-in after repeated failures, for a user name from one address and, with a larger +// limit, for the address whatever names it tries. Kept in memory: a restart clears it. sealed class SignInThrottle(SettingsService settings) { + const int AddressLimitFactor = 4; + sealed class Attempts { public int Failures; @@ -13,40 +15,64 @@ sealed class Attempts readonly ConcurrentDictionary attempts = new(StringComparer.Ordinal); - static string Key(string scope, string name, HttpContext context) => - scope + "\n" + (name ?? "").Trim().ToLowerInvariant() + "\n" + context.Connection.RemoteIpAddress; + // Password hashing is deliberately slow: at most this many run at once, so a flood of + // attempts waits in line instead of taking every processor. + readonly SemaphoreSlim hashing = new(Math.Max(1, Environment.ProcessorCount / 2)); - // The time left in a pause, or null when an attempt may proceed. - public TimeSpan? Paused(string scope, string name, HttpContext context) - { - if (!attempts.TryGetValue(Key(scope, name, context), out var entry)) return null; - lock (entry) - { - var left = entry.PausedUntil - DateTime.UtcNow; - return left > TimeSpan.Zero ? left : null; - } - } + static string NameKey(string scope, string name, HttpContext context) => + scope + "\nname\n" + (name ?? "").Trim().ToLowerInvariant() + "\n" + context.Connection.RemoteIpAddress; + + static string AddressKey(string scope, HttpContext context) => scope + "\naddress\n" + context.Connection.RemoteIpAddress; - public void Failed(string scope, string name, HttpContext context) + // Counts the attempt before its credentials are checked, so attempts sent at the same time + // cannot all get through. Returns the time left in a pause, or null when the attempt may go on. + public TimeSpan? Begin(string scope, string name, HttpContext context) { var security = settings.Security; var now = DateTime.UtcNow; var window = TimeSpan.FromMinutes(security.SignInLockoutMinutes); - var entry = attempts.GetOrAdd(Key(scope, name, context), _ => new Attempts { WindowStart = now }); - lock (entry) - { - if (now - entry.WindowStart > window) { entry.Failures = 0; entry.WindowStart = now; } - if (++entry.Failures >= security.MaxFailedSignIns) + var byName = attempts.GetOrAdd(NameKey(scope, name, context), _ => new Attempts { WindowStart = now }); + var byAddress = attempts.GetOrAdd(AddressKey(scope, context), _ => new Attempts { WindowStart = now }); + // Checking and counting happen under both locks, always taken name first, then address. + lock (byName) + lock (byAddress) { - entry.PausedUntil = now + window; - entry.Failures = 0; - entry.WindowStart = now; + var wait = Max(byName.PausedUntil - now, byAddress.PausedUntil - now); + if (wait > TimeSpan.Zero) return wait; + Count(byName, security.MaxFailedSignIns, now, window); + Count(byAddress, security.MaxFailedSignIns * AddressLimitFactor, now, window); } - } if (attempts.Count > 100_000) Prune(now, window); + return null; + } + + // A successful attempt clears the name's count and is taken back from the address's count. + public void Succeeded(string scope, string name, HttpContext context) + { + attempts.TryRemove(NameKey(scope, name, context), out _); + if (attempts.TryGetValue(AddressKey(scope, context), out var byAddress)) + lock (byAddress) + if (byAddress.Failures > 0) byAddress.Failures--; } - public void Succeeded(string scope, string name, HttpContext context) => attempts.TryRemove(Key(scope, name, context), out _); + public async Task HashAsync(Func work, CancellationToken cancellationToken) + { + await hashing.WaitAsync(cancellationToken); + try { return work(); } + finally { hashing.Release(); } + } + + static TimeSpan Max(TimeSpan first, TimeSpan second) => first > second ? first : second; + + // The caller holds the entry's lock. + static void Count(Attempts entry, int limit, DateTime now, TimeSpan window) + { + if (now - entry.WindowStart > window) { entry.Failures = 0; entry.WindowStart = now; } + if (++entry.Failures < limit) return; + entry.PausedUntil = now + window; + entry.Failures = 0; + entry.WindowStart = now; + } void Prune(DateTime now, TimeSpan window) { diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs b/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs index 101641d..84e7d2b 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Engine/EngineMiddleware.cs @@ -1,43 +1,63 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Routing; using ZoneTree.Analytics; -// Gives reporting and tracker requests the engine they read or write. Primary requests lease the -// configured engine; in Development the sample and benchmark APIs lease their own datasets. +enum EngineSource { Primary, Samples, Benchmark } + +// Marks an endpoint that reads or writes an engine, and which one. Routing decides it, so any +// URL that reaches the endpoint, with or without a trailing slash, gets the engine. +sealed record EngineEndpoint(EngineSource Source, bool IsBeacon); + +static class EngineEndpoints +{ + // Adds the engine to the endpoints of a group whose route pattern the filter accepts. + public static TBuilder UseEngine(this TBuilder builder, EngineSource source, Func pattern = null, + bool beacon = false) where TBuilder : IEndpointConventionBuilder + { + builder.Add(endpoint => + { + if (endpoint is RouteEndpointBuilder route && (pattern?.Invoke(route.RoutePattern.RawText ?? "") ?? true)) + endpoint.Metadata.Add(new EngineEndpoint(source, beacon)); + }); + return builder; + } + + // Ad-hoc queries are for analysts; funnels and standard reports are for every role. + public static TBuilder RequireQueryRoleForQueries(this TBuilder builder) where TBuilder : IEndpointConventionBuilder + { + builder.Add(endpoint => + { + if (endpoint is RouteEndpointBuilder route && IsQuery(route.RoutePattern.RawText)) + endpoint.Metadata.Add(new AuthorizeAttribute(DashboardAuthentication.QueryPolicy)); + }); + return builder; + } + + public static bool IsQuery(string pattern) => pattern?.TrimEnd('/').EndsWith("/query", StringComparison.OrdinalIgnoreCase) == true; +} + +// Gives reporting and tracker endpoints the engine they read or write. Primary endpoints lease +// the configured engine; in Development the sample and benchmark APIs lease their own datasets. sealed class EngineMiddleware(RequestDelegate next, EngineHost engines, SettingsService settings, SiteResolution sites, IServiceProvider services) { public const string ApiPath = "/_analytics/api"; - public const string EventPath = "/_analytics/event"; public const string SiteItem = "zt-dashboard-site"; public async Task InvokeAsync(HttpContext context) { - var path = context.Request.Path; - // Ad-hoc queries are for analysts; funnels and standard reports are for every role. - if (path.StartsWithSegments("/_analytics") && path.Value.EndsWith("/query", StringComparison.OrdinalIgnoreCase) - && DashboardAuthentication.User(context) is { CanQuery: false }) - { - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await context.Response.WriteAsJsonAsync(new { error = "The query explorer needs the analyst role. Ask the administrator for access." }); - return; - } - if (path.StartsWithSegments(SampleDataStore.ApiPath) || path.StartsWithSegments(BenchmarkReportStore.ApiPath)) - { - await SampleAsync(context, path.StartsWithSegments(BenchmarkReportStore.ApiPath)); - return; - } - var isEvent = path.Equals(EventPath, StringComparison.OrdinalIgnoreCase) && HttpMethods.IsPost(context.Request.Method); - if (!isEvent && !path.StartsWithSegments(ApiPath)) + var endpoint = context.GetEndpoint()?.Metadata.GetMetadata(); + if (endpoint == null) { await next(context); return; } - // The dashboard configuration describes the host, not the engine. - if (path.StartsWithSegments(ApiPath + "/config")) + if (endpoint.Source != EngineSource.Primary) { - await next(context); + await SampleAsync(context, endpoint.Source == EngineSource.Benchmark); return; } - if (isEvent) + if (endpoint.IsBeacon) { var current = settings.Current; var site = current == null ? null : sites.Resolve(context, current); @@ -57,7 +77,7 @@ public async Task InvokeAsync(HttpContext context) { context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; context.Response.Headers.RetryAfter = "5"; - if (!isEvent) await context.Response.WriteAsJsonAsync(new { error = error.Message }); + if (!endpoint.IsBeacon) await context.Response.WriteAsJsonAsync(new { error = error.Message }); return; } using (lease) diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs b/apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs index 1ab02ba..21a2685 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Engine/SiteResolution.cs @@ -2,7 +2,9 @@ // Referer, matched against each site's domains. Null means the event is not recorded. sealed class SiteResolution { - sealed record Lookup(DashboardSettings Settings, Dictionary Exact, List<(string Suffix, string Apex, string Site)> Wildcards); + sealed record Wildcard(string Suffix, string Apex, string Site, bool WithPort); + + sealed record Lookup(DashboardSettings Settings, Dictionary Exact, List Wildcards); Lookup lookup; @@ -17,9 +19,14 @@ public string Resolve(HttpContext context, DashboardSettings settings) return settings.Sites.TrackDashboardUsage ? SettingsMapper.DashboardUsageSite : null; var name = host.Split(':')[0]; if (current.Exact.TryGetValue(host, out var site) || current.Exact.TryGetValue(name, out site)) return site; - foreach (var (suffix, apex, wildcardSite) in current.Wildcards) - if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) || string.Equals(name, apex, StringComparison.OrdinalIgnoreCase)) - return wildcardSite; + foreach (var wildcard in current.Wildcards) + { + // A pattern with a port matches only pages served on that port. + var candidate = wildcard.WithPort ? host : name; + if (candidate.EndsWith(wildcard.Suffix, StringComparison.OrdinalIgnoreCase) + || string.Equals(candidate, wildcard.Apex, StringComparison.OrdinalIgnoreCase)) + return wildcard.Site; + } return Unmatched(settings); } @@ -38,16 +45,16 @@ static string SourceHost(HttpContext context) static Lookup Build(DashboardSettings settings) { var exact = new Dictionary(StringComparer.OrdinalIgnoreCase); - var wildcards = new List<(string, string, string)>(); + var wildcards = new List(); foreach (var site in settings.Sites.Items) foreach (var domain in site.Domains) { // *.example.com covers example.com and every subdomain. - if (domain.StartsWith("*.", StringComparison.Ordinal)) wildcards.Add((domain[1..], domain[2..], site.Id)); + if (domain.StartsWith("*.", StringComparison.Ordinal)) wildcards.Add(new(domain[1..], domain[2..], site.Id, domain.Contains(':'))); else exact[domain] = site.Id; } // The longest suffix wins: *.shop.example.com before *.example.com. - wildcards.Sort((first, second) => second.Item1.Length.CompareTo(first.Item1.Length)); + wildcards.Sort((first, second) => second.Suffix.Length.CompareTo(first.Suffix.Length)); return new(settings, exact, wildcards); } } diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Program.cs b/apps/ZoneTree.Analytics.Dashboard/server/Program.cs index f83c593..fbedfb4 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Program.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Program.cs @@ -1,3 +1,5 @@ +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.FileProviders; using ZoneTree.Analytics; using ZoneTree.Analytics.AspNetCore; @@ -28,6 +30,16 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddDashboardAuthentication(); +// Behind a reverse proxy that terminates HTTPS, the origin check, secure cookies and visitor +// addresses need the original scheme, host and client address. Loopback proxies are trusted by +// default; list others with Dashboard:KnownProxies (comma-separated addresses). +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; + foreach (var proxy in (builder.Configuration["Dashboard:KnownProxies"] ?? "").Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + options.KnownProxies.Add(IPAddress.Parse(proxy)); +}); builder.Services.AddZoneTreeAnalyticsWeb(web => { web.MaxConcurrentReportingRequests = SettingsService.StoredConcurrentRequests(systemStore); @@ -38,12 +50,10 @@ var sampleDataEnabled = builder.Environment.IsDevelopment(); if (sampleDataEnabled) { - // The Development datasets follow the reporting time zone chosen during setup. - builder.Services.AddSingleton(services => TimeZoneInfo.FindSystemTimeZoneById( - services.GetRequiredService().Current?.General.TimeZone ?? "UTC")); + // New Development datasets take the reporting time zone current when they are created. builder.Services.AddSingleton(); builder.Services.AddSingleton(services => new SampleBenchmark(contentRoot, - services.GetRequiredService(), services.GetRequiredService>())); + () => services.GetRequiredService().ReportingTimeZone, services.GetRequiredService>())); builder.Services.AddSingleton(); builder.Services.AddHostedService(services => services.GetRequiredService()); } @@ -69,6 +79,7 @@ } } +app.UseForwardedHeaders(); app.UseAuthentication(); app.UseMiddleware(); app.UseMiddleware(); @@ -81,8 +92,12 @@ app.UseAuthorization(); app.UseMiddleware(); -var reportingApi = app.MapZoneTreeAnalyticsApi(EngineMiddleware.ApiPath).RequireAuthorization(); -app.MapZoneTreeAnalytics(); +var reportingApi = app.MapZoneTreeAnalyticsApi(EngineMiddleware.ApiPath) + .RequireAuthorization() + .RequireQueryRoleForQueries() + .UseEngine(EngineSource.Primary, pattern => !pattern.TrimEnd('/').EndsWith("/config", StringComparison.OrdinalIgnoreCase)); +app.MapZoneTreeAnalytics() + .UseEngine(EngineSource.Primary, pattern => pattern.TrimEnd('/').EndsWith("/event", StringComparison.OrdinalIgnoreCase), beacon: true); app.MapDashboardAuth(); app.MapDashboardSetup(); app.MapDashboardAdmin(); @@ -116,8 +131,10 @@ }); if (sampleDataEnabled) { - app.MapZoneTreeAnalyticsApi(SampleDataStore.ApiPath).RequireAuthorization(DashboardAuthentication.AdministratorPolicy); - app.MapZoneTreeAnalyticsApi(BenchmarkReportStore.ApiPath).RequireAuthorization(DashboardAuthentication.AdministratorPolicy); + app.MapZoneTreeAnalyticsApi(SampleDataStore.ApiPath) + .RequireAuthorization(DashboardAuthentication.AdministratorPolicy).UseEngine(EngineSource.Samples); + app.MapZoneTreeAnalyticsApi(BenchmarkReportStore.ApiPath) + .RequireAuthorization(DashboardAuthentication.AdministratorPolicy).UseEngine(EngineSource.Benchmark); var management = app.MapGroup("/_analytics/sample-data").RequireAuthorization(DashboardAuthentication.AdministratorPolicy); management.AddEndpointFilter(async (context, next) => { diff --git a/apps/ZoneTree.Analytics.Dashboard/server/SampleBenchmark.cs b/apps/ZoneTree.Analytics.Dashboard/server/SampleBenchmark.cs index e309fed..72911b5 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/SampleBenchmark.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/SampleBenchmark.cs @@ -18,8 +18,11 @@ sealed record SampleBenchmarkProgress(string State, int Prepared = 0, int Submit // Preparation uses the same realistic generator. The measured interval contains // submission and the final flush; store creation, statistics and cleanup are outside it. -sealed class SampleBenchmark(string contentRoot, TimeZoneInfo timeZone, ILogger logger = null) : IAsyncDisposable +// Each run takes the reporting time zone current when it starts. +sealed class SampleBenchmark(string contentRoot, Func currentTimeZone, ILogger logger = null) : IAsyncDisposable { + public SampleBenchmark(string contentRoot, TimeZoneInfo timeZone, ILogger logger = null) : this(contentRoot, () => timeZone, logger) { } + public BenchmarkReportStore Reports { get; } = new(contentRoot, logger); public static AnalyticsEvent[] PrepareEvents(SampleBenchmarkRequest request, TimeZoneInfo timeZone, @@ -40,6 +43,7 @@ public static AnalyticsEvent[] PrepareEvents(SampleBenchmarkRequest request, Tim public async Task RunAsync(SampleBenchmarkRequest request, Action progress, CancellationToken cancellationToken, CancellationToken applicationStopping) { + var timeZone = currentTimeZone(); var preparation = Stopwatch.StartNew(); var end = DateTimeOffset.UtcNow.AddSeconds(-1); var events = PrepareEvents(request, timeZone, count => progress(new("preparing", Prepared: count)), end, cancellationToken); @@ -47,7 +51,7 @@ public async Task RunAsync(SampleBenchmarkRequest request var directory = Reports.CreateRunDirectory(); try { - var result = await MeasureAsync(directory, events, request, end, preparation.Elapsed.TotalMilliseconds, + var result = await MeasureAsync(directory, events, request, end, timeZone, preparation.Elapsed.TotalMilliseconds, progress, cancellationToken, applicationStopping); if (result == null) return null; progress(new("publishing", events.Length, events.Length, result.Ingested, result.IngestionMs, result.EventsPerSecond)); @@ -58,7 +62,7 @@ public async Task RunAsync(SampleBenchmarkRequest request } async Task MeasureAsync(string directory, AnalyticsEvent[] events, SampleBenchmarkRequest request, DateTimeOffset end, - double preparationMs, Action progress, CancellationToken cancellationToken, CancellationToken applicationStopping) + TimeZoneInfo timeZone, double preparationMs, Action progress, CancellationToken cancellationToken, CancellationToken applicationStopping) { var options = DashboardAnalyticsOptions.CreateBenchmark(directory, timeZone, request.Workers, request.Percentiles, request.Indexes, logger); await using var engine = new AnalyticsEngine(options); diff --git a/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs b/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs index de3c585..998e27d 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/SampleDataStore.cs @@ -2,7 +2,7 @@ // This engine owns only generated data. Reporting leases protect it while a // reset closes the engine and removes all raw events, rollups and metadata. -sealed class SampleDataStore(IWebHostEnvironment environment, SettingsService settings, TimeZoneInfo timeZone, +sealed class SampleDataStore(IWebHostEnvironment environment, SettingsService settings, ILogger logger) : IAsyncDisposable { public const string Site = DashboardAnalyticsOptions.PerformanceSite; @@ -45,7 +45,7 @@ TimeZoneInfo DatasetTimeZone() { var path = Path.Combine(directory, TimeZoneFile); if (File.Exists(path)) return TimeZoneInfo.FindSystemTimeZoneById(File.ReadAllText(path).Trim()); - var zone = Directory.Exists(Path.Combine(directory, "meta")) ? TimeZoneInfo.Utc : timeZone; + var zone = Directory.Exists(Path.Combine(directory, "meta")) ? TimeZoneInfo.Utc : settings.ReportingTimeZone; File.WriteAllText(path, zone.Id); return zone; } diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs index 63a834b..865b80f 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsMapper.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text.RegularExpressions; using ZoneTree.Analytics; using ZoneTree.Analytics.AspNetCore; @@ -31,6 +32,7 @@ public static void Validate(DashboardSettings settings, string contentRoot, stri foreach (var section in typeof(DashboardSettings).GetProperties()) if (section.GetValue(settings) == null) throw new SettingsValidationException($"The {SettingsSchema.Camel(section.Name)} section is missing.", SettingsSchema.Camel(section.Name)); + NormalizeCollections(settings); ValidateNumbers(settings); ValidateGeneral(settings, contentRoot, systemDirectory); ValidateSites(settings.Sites); @@ -48,6 +50,43 @@ public static void Validate(DashboardSettings settings, string contentRoot, stri } } + // A missing list or map means an empty one; a missing group or an empty entry is an error, + // reported with its field instead of failing while the options are built. + static void NormalizeCollections(DashboardSettings settings) + { + foreach (var section in typeof(DashboardSettings).GetProperties()) + Normalize(section.GetValue(settings), SettingsSchema.Camel(section.Name)); + + static void Normalize(object owner, string prefix) + { + foreach (var property in owner.GetType().GetProperties()) + { + var setting = property.GetCustomAttribute(); + if (setting == null || !property.CanWrite) continue; + var key = prefix + "." + SettingsSchema.Camel(property.Name); + var type = property.PropertyType; + var value = property.GetValue(owner); + if (typeof(System.Collections.IEnumerable).IsAssignableFrom(type) && type != typeof(string)) + { + if (value == null) + { + property.SetValue(owner, Activator.CreateInstance(type)); + continue; + } + var items = value is System.Collections.IDictionary map ? map.Values : (System.Collections.IEnumerable)value; + foreach (var item in items) + if (item == null) + throw new SettingsValidationException($"{setting.Label} contains an empty entry.", key); + } + else if (type.IsClass && type != typeof(string)) + { + if (value == null) throw new SettingsValidationException($"{setting.Label} is required.", key); + Normalize(value, key); + } + } + } + } + // Numbers stay inside their [Setting] ranges; an enum holds one of its names. static void ValidateNumbers(DashboardSettings settings) { diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs index 30559b6..4a95d92 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Settings/SettingsService.cs @@ -51,6 +51,9 @@ public SettingsService(SystemStore store, EngineHost engines, AnalyticsWebOption public SecuritySettings Security => current?.Security ?? DefaultSecurity; + // The time zone chosen during setup, or UTC before it. + public TimeZoneInfo ReportingTimeZone => TimeZoneInfo.FindSystemTimeZoneById(current?.General.TimeZone ?? "UTC"); + public bool RestartRequired => current != null && current.Reporting.MaxConcurrentRequests != startupConcurrentRequests; public string ContentRoot => contentRoot; diff --git a/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs index 9e533e5..7d5499b 100644 --- a/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs +++ b/apps/ZoneTree.Analytics.Dashboard/server/Setup/SetupEndpoints.cs @@ -40,14 +40,13 @@ public static void MapDashboardSetup(this IEndpointRouteBuilder app) static IResult Verify(HttpContext context, SetupStatus status, SetupKey key, SignInThrottle throttle, string candidate) { if (status.Complete) return DashboardJson.Error("Setup is already complete. Sign in instead.", StatusCodes.Status409Conflict); - if (throttle.Paused(Scope, null, context) is { } wait) return AuthEndpoints.TooManyAttempts(context, wait); + if (throttle.Begin(Scope, null, context) is { } wait) return AuthEndpoints.TooManyAttempts(context, wait); if (key.Matches(candidate)) { throttle.Succeeded(Scope, null, context); return null; } - throttle.Failed(Scope, null, context); - return DashboardJson.Error("The setup key does not match. Copy it from the server log.", StatusCodes.Status403Forbidden); + return DashboardJson.Error("The setup key does not match. Open the setup link the server printed, or copy its key.", StatusCodes.Status403Forbidden); } static async Task CompleteAsync(HttpContext context, SetupStatus status, SetupKey key, SignInThrottle throttle, diff --git a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts index 872c851..20c551b 100644 --- a/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts +++ b/apps/ZoneTree.Analytics.Dashboard/ui/content/admin/users/users.ts @@ -61,8 +61,12 @@ const template = html`
+ :placeholder="draft.id ? 'Leave empty to keep the current password' : 'At least 10 characters'" + r-if="!draft.administrator"/>
+

+ Change your own password in Account settings. +

Sites