Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
46 changes: 41 additions & 5 deletions apps/ZoneTree.Analytics.Dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,54 @@ 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=<path>`. 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. 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://<your-computer's-LAN-IP>:5088/`.

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
Expand Down
118 changes: 118 additions & 0 deletions apps/ZoneTree.Analytics.Dashboard/server/Admin/AdminEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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<SettingsReviewRequest>(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<UserChange>(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<UserChange>(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));
});
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<IResult> SaveSettingsAsync(HttpContext context, SettingsService settings)
{
var request = await DashboardJson.ReadAsync<SettingsSaveRequest>(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<ChangeView> Changes(IEnumerable<SettingChange> 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<string>();
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)}";
}
}
109 changes: 109 additions & 0 deletions apps/ZoneTree.Analytics.Dashboard/server/Auth/AuthEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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<SiteView> 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<IResult> 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<SignInRequest>(context.Request);
var userName = request.UserName?.Trim() ?? "";
if (userName.Length > 64) userName = userName[..64];
if (throttle.Begin(SignInScope, userName, context) is { } wait)
return TooManyAttempts(context, wait);
var user = users.FindByName(userName);
var valid = await throttle.HashAsync(() => PasswordHasher.Verify(request.Password, user?.PasswordHash), context.RequestAborted)
&& user is { Disabled: false };
if (!valid)
{
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<IResult> UpdateProfileAsync(HttpContext context, UserStore users, SettingsService settings)
{
var request = await DashboardJson.ReadAsync<ProfileRequest>(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<IResult> ChangePasswordAsync(HttpContext context, UserStore users, SessionStore sessions,
SignInThrottle throttle, SettingsService settings, AuditLog audit)
{
var request = await DashboardJson.ReadAsync<PasswordRequest>(context.Request);
var user = DashboardAuthentication.User(context);
if (throttle.Begin("password", user.Id, context) is { } wait)
return TooManyAttempts(context, wait);
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);
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<string> KnownSites(SettingsService settings) =>
settings.Current?.Sites.Items.Select(site => site.Id).ToHashSet(StringComparer.Ordinal) ?? [];
}
Loading
Loading